diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b18061..63e1908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (``.``.``) +## [v1.2.0] +### Changed +* Windowing helper utilities now merge user-specified axes/plot kwargs with defaults, preferring user inputs +* (Internal) Snap checking now utilizes axes native data rather than having to special case multiple numpy datatypes to prevent errors +* Add `NaN` awareness to prevent unexpected behavior when working with datasets containing gaps +* Expand & normalize handling of types expected for xy data, `position`, and `window_width`; all types that matplotlib can plot should also be meaningfully supported + +### Fixed +* Fix `DragRect` completely ignoring user-specified redraw callbacks +* Fix `FlexibleRect` not calling the redraw callback when respanning +* Fix incorrect resizing of `DragRect` on axis limit change +* Fix `DragLine` accidentally validating `snap_to` against xdata for vertical lines + ## [v1.1.0] ### Changed * #9 Dragging is now inhibited while another widget has the canvas locked (e.g. zooming/panning) diff --git a/README.md b/README.md index 541d84a..83071ef 100644 --- a/README.md +++ b/README.md @@ -11,38 +11,44 @@ Draggable data windowing for matplotlib plots. Inspired by the adventures of pas ## Interface For most use cases, interaction with this library is done via the helper wrappers in `matplotlib_window.window`. These functions will accept the user data and build the plots directly for windowing. +#### Units +In addition to numeric data, plotted data and window `position`/`window_width` values may be of any type supported by matplotlib's unit conversion (e.g. `datetime`/`timedelta`, or NumPy's `datetime64`/`timedelta64`). + +Returned window bounds are always provided in the axes' native float units; for datetime data these can be converted back to datetimes using [`matplotlib.dates.num2date`](https://matplotlib.org/stable/api/dates_api.html#matplotlib.dates.num2date). + ### `fixed_window` Plot the provided data & build a draggable fixed-width window to select bounds of interest; the x-locations of the edges of the window are returned once the figure window is closed. #### Parameters -| Parameter | Description | Type | Default | -|----------------|-----------------------------------------------------------------------------|------------------------|------------------| -| `x_data` | x data values to plot | `Sequence[int\|float]` | Required | -| `y_data` | y data values to plot | `Sequence[int\|float]` | Required | -| `position` | x-coordinate of the left edge of the window | `int\|float` | Required | -| `window_width` | Width, along the x-axis, of the draggable window | `int\|float` | Required | -| `snap_to_data` | Prevent dragging of the window beyond beyond the bounds of the plotted data | `bool` | `True` | -| `axes_kwargs` | Optional kwargs to pass to the `Axes` constructor1 | `dict[str, Any]` | `{"title": ...}` | -| `plot_kwargs` | Optional kwargs to pass to the plotting call2 | `dict[str, Any]` | `{}` | - -1. kwargs are passed directly to the `Axes` constructor, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. -2. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. +| Parameter | Description | Type | Default | +|----------------|----------------------------------------------------------------------|------------------|------------------| +| `x_data` | x data values to plot | See: Note 1 | Required | +| `y_data` | y data values to plot | See: Note 1 | Required | +| `position` | x-coordinate of the left edge of the window | See: Note 1 | Required | +| `window_width` | Width, along the x-axis, of the draggable window | See: Note 1 | Required | +| `snap_to_data` | Prevent dragging of the window beyond the bounds of the plotted data | `bool` | `True` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter2 | `dict[str, Any]` | `{"title": ...}` | +| `plot_kwargs` | Optional kwargs to pass to the plotting call3 | `dict[str, Any]` | `{}` | + +1. `int|float`, or any type supported by matplotlib's unit conversion, see: [Units](#units) +2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. ### `flexible_window` Plot the provided data & build a flexible-width window to select bounds of interest; the x-locations of the edges of the window are returned once the figure window is closed. #### Parameters -| Parameter | Description | Type | Default | -|-------------------|-----------------------------------------------------------------------------|------------------------|------------------| -| `x_data` | x data values to plot | `Sequence[int\|float]` | Required | -| `y_data` | y data values to plot | `Sequence[int\|float]` | Required | -| `position` | x-coordinate of the left edge of the window | `int\|float` | Required | -| `window_width` | Starting width, along the x-axis, of the flexible window | `int\|float` | Required | -| `snap_to_data` | Prevent dragging of the window beyond beyond the bounds of the plotted data | `bool` | `True` | -| `allow_face_drag` | Allow dragging of the window using its face1 | `bool` | `False` | -| `axes_kwargs` | Optional kwargs to pass to the `Axes` constructor2 | `dict[str, Any]` | `{"title": ...}` | -| `plot_kwargs` | Optional kwargs to pass to the plotting call3 | `dict[str, Any]` | `{}` | - -1. Currently not implemented -2. kwargs are passed directly to the `Axes` constructor, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. +| Parameter | Description | Type | Default | +|-------------------|----------------------------------------------------------------------|----------------- |------------------| +| `x_data` | x data values to plot | See: Note 1 | Required | +| `y_data` | y data values to plot | See: Note 1 | Required | +| `position` | x-coordinate of the left edge of the window | See: Note 1 | Required | +| `window_width` | Starting width, along the x-axis, of the flexible window | See: Note 1 | Required | +| `snap_to_data` | Prevent dragging of the window beyond the bounds of the plotted data | `bool` | `True` | +| `allow_face_drag` | Allow dragging of the window using its face | `bool` | `False` | +| `axes_kwargs` | Optional kwargs to pass to the `Axes` setter3 | `dict[str, Any]` | `{"title": ...}` | +| `plot_kwargs` | Optional kwargs to pass to the plotting call4 | `dict[str, Any]` | `{}` | + +1. `int|float`, or any type supported by matplotlib's unit conversion, see: [Units](#units) +2. kwargs are passed directly to the `Axes` using `Axes.set()`, see the [`matplotlib.axes.Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib.axes.Axes) for supported arguments. 3. kwargs are passed directly to the plotting call, see the [`matplotlib.pyplot.plot` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) for supported arguments. diff --git a/matplotlib_window/base.py b/matplotlib_window/base.py index 36f2ed9..630a875 100644 --- a/matplotlib_window/base.py +++ b/matplotlib_window/base.py @@ -14,6 +14,8 @@ CALLBACK_T: t.TypeAlias = abc.Callable[[Event], t.Any] PLOT_OBJ_T: t.TypeAlias = Line2D | Rectangle NUMERIC_T: t.TypeAlias = float | int +# Any value supported by matplotlib's unit conversion (e.g. numeric, datetime, timedelta) +UNIT_T: t.TypeAlias = t.Any COMMON_OBJ_ID = "dragobj" # Label created object(s) URL for downstream event filtering @@ -170,22 +172,26 @@ def disconnect(self) -> None: def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: """ - Validate that the `snap_to` object, if provided, actually contains x data. + Validate that the `snap_to` object, if provided, actually contains lineseries xy data. - If `snap_to` is `None`, or is a plot object that contains x data, it is returned unchanged. - Otherwise an exception is raised. + If `snap_to` is `None`, or is a plot object that contains at least one finite xy data point, + it is returned unchanged. Otherwise an exception is raised. """ if snap_to is None: return None try: - xydata = snap_to.get_xydata() + xydata = np.asarray(snap_to.get_xydata()) except AttributeError as e: raise ValueError("Cannot provide an empty lineseries to snapto") from e - if len(xydata) == 0: # type: ignore[arg-type] + if len(xydata) == 0: raise ValueError("Cannot provide an empty lineseries to snapto") + # NaN gaps are fine, but there needs to be at least one plotted point to snap to + if not np.isfinite(xydata).all(axis=1).any(): + raise ValueError("Cannot provide a lineseries without any finite data to snapto") + return snap_to def _disable_click(self) -> None: @@ -200,29 +206,56 @@ def _redraw(self) -> None: self.parent_canvas.draw() -def limit_drag(plotted_data: npt.ArrayLike, query: float) -> float: - """Clamp the query value within the bounds of the provided dataset.""" - # Data series may not be sorted, so use min/max - # I'm not sure how to properly type annotate this right now - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] - - # Per #8, numpy's timedeltas don't support gt/lt so we need to extract a different value for our - # comparison if we're using them - # If the reach ends up being wider then it might be better to perform a more generic check but - # for now the explicit conversion should cover the currently encountered use cases - if isinstance(min_val, np.timedelta64): # min_val and max_val should be the same type - # I couldn't figure out how to access the timedelta unit that numpy holds internally, but - # casting to float seems to work well enough to keep it in the same dimension being used for - # the plot - min_val = min_val.astype(float) - max_val = max_val.astype(float) - - if query > max_val: - return max_val # type: ignore[no-any-return] - elif query < min_val: - return min_val # type: ignore[no-any-return] +def get_native_data(line: Line2D, axis: t.Literal["x", "y"]) -> npt.NDArray[np.float64]: + """ + Return the line's data along the specified axis, converted to the axes' native float units. + + Plotted data may be of any type supported by matplotlib's unit conversion (e.g. datetimes or + timedeltas), but mouse event coordinates & axes limits are always provided in native units. + """ + if axis == "x": + data = line.get_xdata(orig=False) else: - return query + data = line.get_ydata(orig=False) + + return np.asarray(data, dtype=float) + + +def to_native(ax: Axes, value: UNIT_T, axis: t.Literal["x", "y"]) -> float: + """ + Convert the provided value to the axes' native float units along the specified axis. + + This mirrors the conversion performed by matplotlib when plotting data, so `value` may be of any + type supported by matplotlib's unit conversion (e.g. datetimes or timedeltas). Numeric values + are returned as floats. + """ + ax_obj = ax.xaxis if axis == "x" else ax.yaxis + converted = ax_obj.convert_units(value) # type: ignore[no-untyped-call] + + # Types without a registered converter (e.g. timedelta64) are cast to float by matplotlib + return float(np.asarray(converted, dtype=np.float64)) + + +def to_native_span(ax: Axes, start: UNIT_T, span: UNIT_T, axis: t.Literal["x", "y"]) -> float: + """ + Convert the provided span to the axes' native float units along the specified axis. + + Spans may not be independently convertible (e.g. a timedelta on a datetime axis), so the span is + converted using its endpoints relative to `start`. + """ + return to_native(ax, (start + span), axis) - to_native(ax, start, axis) + + +def limit_drag(plotted_data: npt.ArrayLike, query: float) -> float: + """ + Clamp the query value within the bounds of the provided dataset. + + NOTE: `plotted_data` is assumed to be numeric & of the same units as `query`; see + `get_native_data` for converting plotted data to match event coordinates. + """ + # Data series may not be sorted, so use min/max; NaN-aware since plotted data may contain gaps + data = np.asarray(plotted_data, dtype=float) + return float(np.clip(query, np.nanmin(data), np.nanmax(data))) class DragLine(_DraggableObject): @@ -238,12 +271,16 @@ class DragLine(_DraggableObject): All kwargs not explicitly named by `__init__` are passed through to the `Line2D` constructor, allowing the user to specify custom line formatting in a form expected by `Line2D`. + + `position` may be of any type supported by matplotlib's unit conversion (e.g. datetimes), but is + converted to the axes' native float units on instantiation; `location` is always provided in + native units. """ def __init__( self, ax: Axes, - position: NUMERIC_T, + position: UNIT_T, orientation: Orientation = Orientation.VERTICAL, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, @@ -253,6 +290,12 @@ def __init__( self.orientation = orientation self.redraw_callback = redraw_callback + # Work in native units internally so positions are directly comparable to event coordinates + if orientation == Orientation.HORIZONTAL: + position = to_native(ax, position, "y") + else: + position = to_native(ax, position, "x") + line_pos = (position, position) # matplotlib expectes a coordinate pair if orientation == Orientation.HORIZONTAL: obj = Line2D(xdata=ax.get_xlim(), ydata=line_pos, color=color, **kwargs) @@ -290,14 +333,14 @@ def on_motion(self, event: Event) -> t.Any: if self.orientation == Orientation.HORIZONTAL: if self.snap_to: - new_pos = limit_drag(self.snap_to.get_ydata(), event.ydata) + new_pos = limit_drag(get_native_data(self.snap_to, "y"), event.ydata) else: new_pos = event.ydata self.myobj.set_ydata((new_pos, new_pos)) elif self.orientation == Orientation.VERTICAL: if self.snap_to: - new_pos = limit_drag(self.snap_to.get_xdata(), event.xdata) + new_pos = limit_drag(get_native_data(self.snap_to, "x"), event.xdata) else: new_pos = event.xdata @@ -320,10 +363,10 @@ def limit_change(self, ax: Axes) -> None: def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: """ - Validate that the `snap_to` object, if provided, actually contains x data. + Validate that the `snap_to` object, if provided, bounds the line being created. - If `snap_to` is `None`, or is a plot object that contains x data, it is returned unchanged. - Otherwise an exception is raised. + If `snap_to` is `None`, or is a plot object whose data along the line's axis of motion + contains the line's location, it is returned unchanged. Otherwise an exception is raised. NOTE: This should be called after the draggable object is registered so the object is instantiated & references are set. @@ -334,9 +377,14 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Superclass implementation handles checking that the lineseries contains data super().validate_snap_to(snap_to) - # Check that the draggable line is within the bounds of the snap_to lineseries - plotted_data = snap_to.get_xdata() - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] + # Check that the draggable line is within the bounds of the snap_to lineseries along its + # axis of motion + if self.orientation == Orientation.HORIZONTAL: + plotted_data = get_native_data(snap_to, "y") + else: + plotted_data = get_native_data(snap_to, "x") + + min_val, max_val = np.nanmin(plotted_data), np.nanmax(plotted_data) if not (min_val <= self.location <= max_val): raise ValueError("DragLine must be within the bounds of the provided snapto line") @@ -353,6 +401,18 @@ def location(self) -> NUMERIC_T: return pos[0] # Should be a (location, location) tuple + def set_location(self, position: NUMERIC_T) -> None: + """ + Set the location of the `DragLine` along its relevant axis, in native axes units. + + NOTE: The canvas is not redrawn & `redraw_callback` is not called; the caller is responsible + for redrawing as needed. + """ + if self.orientation == Orientation.VERTICAL: + self.myobj.set_xdata((position, position)) + else: + self.myobj.set_ydata((position, position)) + class RectParams(t.NamedTuple): # noqa: D101 xy: COORD_T @@ -390,14 +450,22 @@ class DragRect(_DraggableObject): All kwargs not explicitly named by `__init__` are passed through to the `Rectangle` constructor, allowing the user to specify custom line formatting in a form expected by `Rectangle`. + `position` and `width` may be of any type supported by matplotlib's unit conversion (e.g. a + datetime & timedelta), but are converted to the axes' native float units on instantiation; + `bounds` is always provided in native units. + NOTE: Motion is constrained to the x-axis only. """ + # Left edge location when the current drag started, used for drag deltas so the object doesn't + # jump to the cursor + drag_start_x: float + def __init__( self, ax: Axes, - position: NUMERIC_T, - width: NUMERIC_T, + position: UNIT_T, + width: UNIT_T, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, edgecolor: str | None = "limegreen", @@ -405,10 +473,15 @@ def __init__( alpha: NUMERIC_T = 0.4, **kwargs: t.Any, ) -> None: - if width <= 0: + # Work in native units internally so positions are directly comparable to event coordinates + native_width = to_native_span(ax, position, width, "x") + if native_width <= 0: raise ValueError(f"Width value must be greater than 0. Received: {width}") - self.redraw_callback = None + position = to_native(ax, position, "x") + width = native_width + + self.redraw_callback = redraw_callback # Rectangle patches are located from their bottom left corner; because we want to span the # full y range, we need to translate the y position to the bottom of the axes @@ -424,12 +497,25 @@ def __init__( **kwargs, ) - self.oldxy = rect_params.xy # Used for drag deltas so the object doesn't jump to cursor self.register_plot_object(obj, ax) self.axes_limit_change = ax.callbacks.connect("ylim_changed", self.limit_change) self.snap_to = self.validate_snap_to(snap_to) + def on_click(self, event: Event) -> t.Any: + """ + Mouse click callback. + + In addition to the common click handling, cache the location of the left edge if a drag is + started. This is cached at the start of every drag, rather than at the end of the previous + one, so it remains correct if the rectangle is moved by other means between drags (e.g. by + a `FlexibleRect`'s edges). + """ + super().on_click(event) + + if self.clicked: + self.drag_start_x = self.myobj.get_x() + def on_motion(self, event: Event) -> t.Any: """ On motion callback. @@ -453,18 +539,18 @@ def on_motion(self, event: Event) -> t.Any: # of the bottom left corner when the object was clicked on. Because we can click anywhere on # the patch to begin motion, the patch will jump to the mouse if just using the location of # the MouseEvent. - old_x, _ = self.oldxy + old_x = self.drag_start_x dx = event.xdata - self.click_x if self.snap_to: if dx < 0: # Moving left, check left edge query = old_x + dx - new_x = limit_drag(self.snap_to.get_xdata(), query) + new_x = limit_drag(get_native_data(self.snap_to, "x"), query) else: # Moving right, check right edge width = self.myobj.get_width() query = old_x + width + dx - new_x = limit_drag(self.snap_to.get_xdata(), query) - width + new_x = limit_drag(get_native_data(self.snap_to, "x"), query) - width else: new_x = old_x + dx @@ -473,27 +559,14 @@ def on_motion(self, event: Event) -> t.Any: self._redraw() - def on_release(self, event: Event) -> t.Any: - """ - Mouse button release callback. - - When the mouse button is released, cache the new corner location & disconnect the callbacks - connected by `self.on_click`. - """ - if not isinstance(event, MouseEvent): - # Type narrowing, matplotlib dispatches a MouseEvent here so shouldn't ever trip this - return - - self.oldxy = self.myobj.get_xy() - self.disconnect() - def limit_change(self, ax: Axes) -> None: """ Axes limit change callback. Resize the rectangle to span the entirety of the y-axis if the axis limit is changed. """ - rect_params = transform_rect_params(ax, 0) # Doesn't matter what the x is, only need height + rect_params = transform_rect_params(ax, self.myobj.get_x()) + self.myobj.set_xy(rect_params.xy) self.myobj.set_height(rect_params.height) self._redraw() @@ -515,8 +588,8 @@ def validate_snap_to(self, snap_to: Line2D | None) -> Line2D | None: # Check that the draggable rectangle is within the bounds of the snap_to lineseries l_pos, r_pos = self.bounds - plotted_data = snap_to.get_xdata() - min_val, max_val = plotted_data.min(), plotted_data.max() # type: ignore[union-attr] + plotted_data = get_native_data(snap_to, "x") + min_val, max_val = np.nanmin(plotted_data), np.nanmax(plotted_data) if not (min_val <= l_pos <= max_val) or not (min_val <= r_pos <= max_val): raise ValueError("DragRect must be within the bounds of the provided snapto line") @@ -542,14 +615,20 @@ class FlexibleRect: location of the line has been changed. This callable is expected to take no arguments and has no return. + `position` and `width` may be of any type supported by matplotlib's unit conversion (e.g. a + datetime & timedelta), but are converted to the axes' native float units on instantiation; + `bounds` is always provided in native units. + + If `allow_face_drag` is `True`, the entire rectangle may also be dragged using its face. + NOTE: Motion is constrained to the x-axis only. """ def __init__( self, ax: Axes, - position: NUMERIC_T, - width: NUMERIC_T, + position: UNIT_T, + width: UNIT_T, snap_to: Line2D | None = None, redraw_callback: abc.Callable[[], None] | None = None, allow_face_drag: bool = False, @@ -557,9 +636,14 @@ def __init__( facecolor: str = "limegreen", alpha: NUMERIC_T = 0.4, ) -> None: - if width <= 0: + # Work in native units internally so the edge positions can be calculated directly + native_width = to_native_span(ax, position, width, "x") + if native_width <= 0: raise ValueError(f"Width value must be greater than 0. Received: {width}") + position = to_native(ax, position, "x") + width = native_width + self.parent_axes = ax if ax.figure is not None: self.parent_canvas = ax.figure.canvas @@ -570,8 +654,16 @@ def __init__( # snap_to validation handled by DragRect & DragLine # Create edges after face so they're topmost & take click priority + # If the face is draggable, it snaps as a whole & drags the edges along with it self.face = DragRect( - ax=ax, position=position, width=width, facecolor=facecolor, edgecolor=None, alpha=alpha + ax=ax, + position=position, + width=width, + snap_to=(snap_to if allow_face_drag else None), + redraw_callback=(self._move_edges if allow_face_drag else None), + facecolor=facecolor, + edgecolor=None, + alpha=alpha, ) line_p = partial( @@ -585,8 +677,18 @@ def __init__( if not allow_face_drag: self.face._disable_click() - else: - raise NotImplementedError + + def _move_edges(self) -> None: + """Move the edges to the bounds of the face, called when the face has been dragged.""" + left, right = self.face.bounds + + # Edges may have been dragged past each other, so match them to the face by location + left_edge, right_edge = sorted(self.edges, key=lambda edge: edge.location) + left_edge.set_location(left) + right_edge.set_location(right) + + if self.redraw_callback is not None: + self.redraw_callback() def _respan_face(self) -> None: """Update face dimensions to span the entirety of the y-axes between the two edges.""" @@ -599,6 +701,9 @@ def _respan_face(self) -> None: self.face.myobj.set_xy(rect_params.xy) self.face.myobj.set_width(width) + if self.redraw_callback is not None: + self.redraw_callback() + self.parent_canvas.draw() # Call directly to avoid infinitely spamming the callback @property diff --git a/matplotlib_window/window.py b/matplotlib_window/window.py index a302051..51541ea 100644 --- a/matplotlib_window/window.py +++ b/matplotlib_window/window.py @@ -3,7 +3,7 @@ import matplotlib.pyplot as plt -from matplotlib_window.base import DragRect, FlexibleRect, NUMERIC_T +from matplotlib_window.base import DragRect, FlexibleRect, NUMERIC_T, UNIT_T DEFAULT_AXES_KWARGS: dict[str, t.Any] = { "title": "Close window to return selected window bounds", @@ -13,13 +13,13 @@ def fixed_window( - x_data: abc.Sequence[NUMERIC_T], - y_data: abc.Sequence[NUMERIC_T], - position: NUMERIC_T, - window_width: NUMERIC_T, + x_data: abc.Sequence[UNIT_T], + y_data: abc.Sequence[UNIT_T], + position: UNIT_T, + window_width: UNIT_T, snap_to_data: bool = True, - axes_kwargs: dict[str, t.Any] = DEFAULT_AXES_KWARGS, - plot_kwargs: dict[str, t.Any] = DEFAULT_PLOT_KWARGS, + axes_kwargs: dict[str, t.Any] | None = None, + plot_kwargs: dict[str, t.Any] | None = None, ) -> tuple[NUMERIC_T, NUMERIC_T]: """ Plot the provided data & build a draggable fixed-width window to select bounds of interest. @@ -28,16 +28,26 @@ def fixed_window( `position` specifies the x-coordinate of the left edge of the window. + NOTE: xy data, `position`, and `window_width` may be of any type supported by matplotlib's unit + conversion (e.g. datetimes & timedeltas), but the returned edge locations are always in the + axes' native float units; for datetime data these may be converted back using + `matplotlib.dates.num2date`. + If `snap_to_data` is `True`, the window is prevented from being dragged beyond the bounds of the plotted data. `axes_kwargs` and `plot_kwargs` may be optionally specified to control the appearance of the resulting `Axes` and `Line2D` objects, respectively, and are passed straight through to their respective objects. Consult their respective documentation for available parameters. + + NOTE: User specified axes/plot kwargs are merged with the module level defaults, where the user + input wins on conflict. """ _, ax = plt.subplots() - ax.set(**axes_kwargs) - ls = ax.plot(x_data, y_data, **plot_kwargs) + + # Merge any user defined axes/plot kwargs with default; user wins vs. default + ax.set(**(DEFAULT_AXES_KWARGS | (axes_kwargs or {}))) + ls = ax.plot(x_data, y_data, **(DEFAULT_PLOT_KWARGS | (plot_kwargs or {}))) if snap_to_data: snap_to = ls[0] @@ -51,14 +61,14 @@ def fixed_window( def flexible_window( - x_data: abc.Sequence[NUMERIC_T], - y_data: abc.Sequence[NUMERIC_T], - position: NUMERIC_T, - window_width: NUMERIC_T, + x_data: abc.Sequence[UNIT_T], + y_data: abc.Sequence[UNIT_T], + position: UNIT_T, + window_width: UNIT_T, snap_to_data: bool = True, allow_face_drag: bool = False, - axes_kwargs: dict[str, t.Any] = DEFAULT_AXES_KWARGS, - plot_kwargs: dict[str, t.Any] = DEFAULT_PLOT_KWARGS, + axes_kwargs: dict[str, t.Any] | None = None, + plot_kwargs: dict[str, t.Any] | None = None, ) -> tuple[NUMERIC_T, NUMERIC_T]: """ Plot the provided data & build a flexible-width window to select bounds of interest. @@ -67,19 +77,28 @@ def flexible_window( `position` specifies the x-coordinate of the left edge of the window. + NOTE: xy data, `position`, and `window_width` may be of any type supported by matplotlib's unit + conversion (e.g. datetimes & timedeltas), but the returned edge locations are always in the + axes' native float units; for datetime data these may be converted back using + `matplotlib.dates.num2date`. + If `snap_to_data` is `True`, the window is prevented from being dragged beyond the bounds of the plotted data. - If `allow_face_drag` is `True`, the entire window may be dragged using its face. NOTE: This is - currently not implemented. + If `allow_face_drag` is `True`, the entire window may also be dragged using its face. `axes_kwargs` and `plot_kwargs` may be optionally specified to control the appearance of the resulting `Axes` and `Line2D` objects, respectively, and are passed straight through to their respective objects. Consult their respective documentation for available parameters. + + NOTE: User specified axes/plot kwargs are merged with the module level defaults, where the user + input wins on conflict. """ _, ax = plt.subplots() - ax.set(**axes_kwargs) - ls = ax.plot(x_data, y_data, **plot_kwargs) + + # Merge any user defined axes/plot kwargs with default; user wins vs. default + ax.set(**(DEFAULT_AXES_KWARGS | (axes_kwargs or {}))) + ls = ax.plot(x_data, y_data, **(DEFAULT_PLOT_KWARGS | (plot_kwargs or {}))) if snap_to_data: snap_to = ls[0] diff --git a/pyproject.toml b/pyproject.toml index c7c8472..b971925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ "pre-commit~=4.0", "pytest~=9.0", "pytest-cov~=7.0", + "pytest-mock~=3.17", "pytest-randomly~=5.0", "ruff~=0.7", "sco1-bumper~=2.0", diff --git a/tests/helpers.py b/tests/helpers.py index 6e9de55..81126a8 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,13 @@ -from matplotlib.backend_bases import FigureCanvasBase +import datetime as dt +import typing as t + +import numpy as np +import pytest +from matplotlib.axes import Axes +from matplotlib.backend_bases import FigureCanvasBase, MouseButton, MouseEvent +from matplotlib.lines import Line2D + +from matplotlib_window.base import get_native_data def has_callback_to( @@ -16,3 +25,63 @@ def has_callback_to( return True return False + + +def fire_event(ax: Axes, name: str, xdata: float, ydata: float) -> None: + """ + Dispatch a `MouseEvent` of the provided `name` to the axes' canvas. + + NOTE: `xdata` and `ydata` are expected in the axes' native float units. + """ + if ax.figure is None: + raise ValueError("Axes must belong to a figure.") + + canvas = ax.figure.canvas + x, y = ax.transData.transform((xdata, ydata)) + event = MouseEvent(name, canvas, x, y, button=MouseButton.LEFT) + canvas.callbacks.process(name, event) + + +def drag(ax: Axes, start: tuple[float, float], end: tuple[float, float]) -> None: + """Simulate a click & drag from `start` to `end`, provided in the axes' native float units.""" + fire_event(ax, "button_press_event", *start) + fire_event(ax, "motion_notify_event", *end) + fire_event(ax, "button_release_event", *end) + + +EMPTY_LINE = Line2D(xdata=np.array([]), ydata=np.array([])) + + +ALL_NAN_LINE = Line2D(xdata=np.full(3, np.nan), ydata=np.full(3, np.nan)) + + +# NaN gaps, including at the ends, are a common way to represent missing data +GAPPED_X = np.array([np.nan, 0, 1, np.nan, 3, 4, np.nan]) +GAPPED_Y = np.array([np.nan, 10, 11, np.nan, 13, 14, np.nan]) + + +# Make sure x & y data are different so we're not masking issues +DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([10, 11, 12])) + + +# snap_to data should work for any type supported by matplotlib's unit conversion, so the tests +# below are run against a range of data types rather than special-casing each one +SNAP_TO_DATA: dict[str, t.Any] = { + "numeric": np.arange(5), + "datetime64[D]": np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"), + "datetime64[h]": np.arange("2026-01-01T00", "2026-01-01T05", dtype="datetime64[h]"), + "datetime64[ns]": np.arange(5).astype("timedelta64[s]") + np.datetime64("2026-01-01", "ns"), + "timedelta64[D]": np.arange(1, 6).astype("timedelta64[D]"), # See: #8 + "timedelta64[s]": np.arange(1, 6).astype("timedelta64[s]"), + "datetime": [dt.datetime(2026, 1, day) for day in range(1, 6)], + "date": [dt.date(2026, 1, day) for day in range(1, 6)], +} +SNAP_TO_DATA_PARAMS = pytest.mark.parametrize( + "data", SNAP_TO_DATA.values(), ids=SNAP_TO_DATA.keys() +) + + +def native_extents(line: Line2D, axis: t.Literal["x", "y"]) -> tuple[float, float, float]: + """Return the min, max, & span of the line's data along `axis`, in native axes units.""" + native = get_native_data(line, axis) + return native.min(), native.max(), (native.max() - native.min()) diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 8dcc960..6071e06 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -1,8 +1,17 @@ -import matplotlib.pyplot as plt +import matplotlib.dates as mdates import numpy as np import pytest -from matplotlib_window.base import NUMERIC_T, RectParams, limit_drag, transform_rect_params +from matplotlib_window.base import ( + NUMERIC_T, + RectParams, + get_native_data, + limit_drag, + to_native, + to_native_span, + transform_rect_params, +) +from tests.conftest import PLOTOBJ_T PLOTTED_DATA = np.array([1, 2, 3, 4, 5]) LIMIT_DRAG_TEST_CASES = ( @@ -24,28 +33,57 @@ def test_limit_drag(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: assert limit_drag(plotted_data=PLOTTED_DATA, query=query) == pytest.approx(truth_out) -PLOTTED_TIMEDELTA = np.array([np.timedelta64(i) for i in range(1, 6)]) -NP_TIMEDELTA_LIMIT_DRAG_TEST_CASES = ( - (-1, 1), - (-1.0, 1), - (1, 1), - (1.0, 1.0), - (3, 3), - (3.0, 3), - (5, 5), - (5.0, 5), - (7, 5), - (7.0, 5), -) +def test_limit_drag_unsorted_data() -> None: + assert limit_drag(plotted_data=np.array([3, 5, 1, 4, 2]), query=7) == pytest.approx(5) + + +GAPPED_DATA = np.array([np.nan, 1, 2, np.nan, 4, 5, np.nan]) + + +@pytest.mark.parametrize(("query", "truth_out"), LIMIT_DRAG_TEST_CASES) +def test_limit_drag_nan_gaps(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: + assert limit_drag(plotted_data=GAPPED_DATA, query=query) == pytest.approx(truth_out) + + +def test_get_native_data_numeric(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot([1, 2, 3], [4, 5, 6])[0] + + np.testing.assert_allclose(get_native_data(ls, "x"), [1, 2, 3]) + np.testing.assert_allclose(get_native_data(ls, "y"), [4, 5, 6]) + + +def test_get_native_data_datetime(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dates = np.arange("2026-01-01", "2026-01-04", dtype="datetime64[D]") + ls = ax.plot(dates, dates)[0] + + truth_data = mdates.date2num(dates) # type: ignore[no-untyped-call] + np.testing.assert_allclose(get_native_data(ls, "x"), truth_data) + np.testing.assert_allclose(get_native_data(ls, "y"), truth_data) + + +def test_to_native_numeric(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + native = to_native(ax, 2, "x") + + assert isinstance(native, float) + assert native == pytest.approx(2) + assert to_native_span(ax, 2, 3, "x") == pytest.approx(3) + +def test_to_native_datetime(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + start = np.datetime64("2026-01-02T00", "h") + span = np.timedelta64(6, "h") -@pytest.mark.parametrize(("query", "truth_out"), NP_TIMEDELTA_LIMIT_DRAG_TEST_CASES) -def test_limit_np_timedelta_drag(query: NUMERIC_T, truth_out: NUMERIC_T) -> None: - assert limit_drag(plotted_data=PLOTTED_TIMEDELTA, query=query) == pytest.approx(truth_out) + truth_start = mdates.date2num(start) # type: ignore[no-untyped-call] + assert to_native(ax, start, "y") == pytest.approx(truth_start) + assert to_native_span(ax, start, span, "y") == pytest.approx(0.25) # Fraction of a day -def test_transform_rect() -> None: - _, ax = plt.subplots() +def test_transform_rect(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj ax.set(xlim=(-10, 10), ylim=(-10, 10)) truth_params = RectParams(xy=(0, -10), height=20) diff --git a/tests/test_base_objs.py b/tests/test_base_objs.py deleted file mode 100644 index 22a71db..0000000 --- a/tests/test_base_objs.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from matplotlib_window.base import DragLine, DragRect, FlexibleRect, Orientation -from tests.conftest import PLOTOBJ_T -from tests.helpers import has_callback_to - - -def test_dragline_invalid_orientation_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="orientation"): - _ = DragLine(ax=ax, position=0, orientation="beans") # type: ignore[arg-type] - - -DRAGLINE_LOCATION_TEST_CASES = ( - (Orientation.VERTICAL, 1), - (Orientation.VERTICAL, 1), -) - - -@pytest.mark.parametrize(("orientation", "truth_location"), DRAGLINE_LOCATION_TEST_CASES) -def test_dragline_get_location( - orientation: Orientation, truth_location: int, plotobj: PLOTOBJ_T -) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=1, orientation=orientation) - assert dl.location == truth_location - - -def test_dragrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="greater than"): - _ = DragRect(ax=ax, position=0, width=0) - - -def test_dragrect_get_bounds(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - assert dr.bounds == (0, 1) - - -def test_dragrect_click_disable(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - - parent_canvas = dr.parent_canvas - assert has_callback_to(parent_canvas, "DragRect") - - dr._disable_click() - assert dr.click_press == -1 - assert not has_callback_to(parent_canvas, "DragRect") - - -def test_flexrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - with pytest.raises(ValueError, match="greater than"): - _ = FlexibleRect(ax=ax, position=0, width=0) - - -def test_flexrect_get_bounds(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = FlexibleRect(ax=ax, position=0, width=1) - assert dr.bounds == (0, 1) - - -def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = FlexibleRect(ax=ax, position=0, width=1, allow_face_drag=False) - - parent_canvas = dr.face.parent_canvas - assert not has_callback_to(parent_canvas, "DragRect") diff --git a/tests/test_dragline.py b/tests/test_dragline.py new file mode 100644 index 0000000..58abca2 --- /dev/null +++ b/tests/test_dragline.py @@ -0,0 +1,230 @@ +import typing as t + +import pytest +from pytest_mock import MockerFixture + +from matplotlib_window.base import DragLine, Orientation, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import ( + ALL_NAN_LINE, + DUMMY_LINE, + EMPTY_LINE, + GAPPED_X, + GAPPED_Y, + SNAP_TO_DATA_PARAMS, + drag, + native_extents, +) + + +def test_dragline_invalid_orientation_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="orientation"): + _ = DragLine(ax=ax, position=0, orientation="beans") # type: ignore[arg-type] + + +DRAGLINE_LOCATION_TEST_CASES = ( + (Orientation.VERTICAL, 1), + (Orientation.VERTICAL, 1), +) + + +@pytest.mark.parametrize(("orientation", "truth_location"), DRAGLINE_LOCATION_TEST_CASES) +def test_dragline_get_location( + orientation: Orientation, truth_location: int, plotobj: PLOTOBJ_T +) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=1, orientation=orientation) + assert dl.location == truth_location + + +def test_dragline_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dl = DragLine(ax=ax, position=0, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dl.redraw_callback is callback + + +def test_dragline_set_location_vertical(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0.2, orientation=Orientation.VERTICAL) + + dl.set_location(0.7) + assert dl.location == pytest.approx(0.7) + assert get_native_data(dl.myobj, "x") == pytest.approx([0.7, 0.7]) + + +def test_dragline_set_location_horizontal(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0.2, orientation=Orientation.HORIZONTAL) + + dl.set_location(0.7) + assert dl.location == pytest.approx(0.7) + assert get_native_data(dl.myobj, "y") == pytest.approx([0.7, 0.7]) + + +def test_dragline_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + assert dl.validate_snap_to(None) is None + + +def test_dragline_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + with pytest.raises(ValueError, match="empty"): + dl.validate_snap_to(EMPTY_LINE) + + +def test_dragline_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax=ax, position=0) + with pytest.raises(ValueError, match="finite"): + dl.validate_snap_to(ALL_NAN_LINE) + + +def test_vertical_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=2, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (2, 12), (6, 12)) + assert dl.location == pytest.approx(4) + + drag(ax, (4, 12), (-2, 12)) + assert dl.location == pytest.approx(0) + + +def test_horizontal_dragline_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_ylim(6, 18) # Leave room to drag beyond the data inside the axes + dl = DragLine(ax, position=12, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, 12), (2, 16)) + assert dl.location == pytest.approx(14) + + drag(ax, (2, 14), (2, 8)) + assert dl.location == pytest.approx(10) + + +def test_dragline_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=5, snap_to=ls) + + +def test_vertical_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=3, orientation=Orientation.VERTICAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_horizontal_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=3, orientation=Orientation.HORIZONTAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_vertical_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragLine(ax, position=1, orientation=Orientation.VERTICAL, snap_to=DUMMY_LINE) + + +def test_horizontal_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragLine(ax, position=11, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) + + +def test_vertical_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=101, orientation=Orientation.VERTICAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +def test_horizontal_dragline_snapto_oob_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dl = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL) + + with pytest.raises(ValueError, match="bounds"): + dl.validate_snap_to(DUMMY_LINE) + + +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "x")[1] + dl = DragLine(ax, position=start, orientation=Orientation.VERTICAL, snap_to=ls) + + drag(ax, (start, 2), (hi + span / 2, 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (hi, 2), (lo - span / 2, 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + lo, hi, span = native_extents(ls, "y") + ax.set_ylim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + start = get_native_data(ls, "y")[1] + dl = DragLine(ax, position=start, orientation=Orientation.HORIZONTAL, snap_to=ls) + + drag(ax, (2, start), (2, hi + span / 2)) + assert dl.location == pytest.approx(hi, abs=span * 1e-6) + + drag(ax, (2, hi), (2, lo - span / 2)) + assert dl.location == pytest.approx(lo, abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragline_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragLine(ax, position=(hi + span), snap_to=ls) + + +# Positions & widths may also be provided in the plotted data's own units (e.g. a datetime & +# timedelta), but are always reported back in the axes' native float units +@SNAP_TO_DATA_PARAMS +def test_vertical_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dl = DragLine(ax, position=data[1], orientation=Orientation.VERTICAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) + + +@SNAP_TO_DATA_PARAMS +def test_horizontal_dragline_data_type_position(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(range(5), data)[0] + native = get_native_data(ls, "y") + + dl = DragLine(ax, position=data[1], orientation=Orientation.HORIZONTAL, snap_to=ls) + assert isinstance(dl.location, float) + assert dl.location == pytest.approx(native[1]) diff --git a/tests/test_dragobj.py b/tests/test_dragobj.py new file mode 100644 index 0000000..e5f1ab3 --- /dev/null +++ b/tests/test_dragobj.py @@ -0,0 +1,21 @@ +import pytest + +from matplotlib_window.base import _DraggableObject +from tests.helpers import ALL_NAN_LINE, EMPTY_LINE + + +def test_dragobj_snapto_none_passthrough() -> None: + do = _DraggableObject() + assert do.validate_snap_to(None) is None + + +def test_dragobj_snapto_empty_data_raises() -> None: + do = _DraggableObject() + with pytest.raises(ValueError, match="empty"): + do.validate_snap_to(EMPTY_LINE) + + +def test_dragobj_snapto_all_nan_data_raises() -> None: + do = _DraggableObject() + with pytest.raises(ValueError, match="finite"): + do.validate_snap_to(ALL_NAN_LINE) diff --git a/tests/test_dragrect.py b/tests/test_dragrect.py new file mode 100644 index 0000000..c8e5648 --- /dev/null +++ b/tests/test_dragrect.py @@ -0,0 +1,189 @@ +import typing as t + +import pytest +from pytest_mock import MockerFixture + +from matplotlib_window.base import DragRect, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import ( + ALL_NAN_LINE, + DUMMY_LINE, + EMPTY_LINE, + GAPPED_X, + GAPPED_Y, + SNAP_TO_DATA_PARAMS, + drag, + has_callback_to, + native_extents, +) + + +def test_dragrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="greater than"): + _ = DragRect(ax=ax, position=0, width=0) + + +def test_dragrect_get_bounds(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + assert dr.bounds == (0, 1) + + +def test_dragrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert dr.myobj.get_xy() == pytest.approx((0.2, 5)) + assert dr.myobj.get_height() == pytest.approx(10) + + +def test_dragrect_click_disable(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + + parent_canvas = dr.parent_canvas + assert has_callback_to(parent_canvas, "DragRect") + + dr._disable_click() + assert dr.click_press == -1 + assert not has_callback_to(parent_canvas, "DragRect") + + +def test_dragrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + dr = DragRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert dr.redraw_callback is callback + + +def test_dragrect_drag_after_external_move(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0.2, width=0.3) + + # Drag deltas should be relative to the rectangle's location when the drag starts, even if it + # was moved by something other than a previous drag + dr.myobj.set_x(0.4) + drag(ax, (0.55, 0.5), (0.65, 0.5)) + assert dr.bounds == pytest.approx((0.5, 0.8)) + + +def test_dragrect_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + assert dr.validate_snap_to(None) is None + + +def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + with pytest.raises(ValueError, match="empty"): + dr.validate_snap_to(EMPTY_LINE) + + +def test_dragrect_snapto_all_nan_data_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=0, width=1) + with pytest.raises(ValueError, match="finite"): + dr.validate_snap_to(ALL_NAN_LINE) + + +def test_dragrect_snapto_nan_gaps(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + ax.set_xlim(-4, 8) # Leave room to drag beyond the data inside the axes + dr = DragRect(ax, position=1, width=1, snap_to=ls) + + drag(ax, (1.5, 12), (5.5, 12)) + assert dr.bounds == pytest.approx((3, 4)) + + drag(ax, (3.5, 12), (-0.5, 12)) + assert dr.bounds == pytest.approx((0, 1)) + + +def test_dragrect_snapto_nan_gaps_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(GAPPED_X, GAPPED_Y)[0] + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=3, width=2, snap_to=ls) + + +RECT_BOUNDS_TEST_CASES = ( + (-1, 1), # Left edge out + (0, 3), # Right edge out + (-1, 4), # Both edges out +) + + +@pytest.mark.parametrize(("position", "width"), RECT_BOUNDS_TEST_CASES) +def test_dragrect_snapto_out_of_bounds_raises( + position: int, width: int, plotobj: PLOTOBJ_T +) -> None: + _, ax = plotobj + dr = DragRect(ax=ax, position=position, width=width) + + with pytest.raises(ValueError, match="bounds"): + dr.validate_snap_to(DUMMY_LINE) + + +def test_dragrect_valid_snapto(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + _ = DragRect(ax=ax, position=0, width=1, snap_to=DUMMY_LINE) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + lo, hi, span = native_extents(ls, "x") + ax.set_xlim(lo - span, hi + span) # Leave room to drag beyond the data inside the axes + + native = get_native_data(ls, "x") + width = native[2] - native[1] + dr = DragRect(ax, position=native[1], width=width, snap_to=ls) + + # Drag from the middle of the rectangle so the click lands on the patch; dragging by the full + # data span pushes the leading edge beyond the data while keeping the cursor inside the axes + mid = native[1] + width / 2 + drag(ax, (mid, 2), (mid + span, 2)) + assert dr.bounds == pytest.approx((hi - width, hi), abs=span * 1e-6) + + mid = hi - width / 2 + drag(ax, (mid, 2), (mid - span, 2)) + assert dr.bounds == pytest.approx((lo, lo + width), abs=span * 1e-6) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_snapto_data_types_out_of_bounds_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + _, hi, span = native_extents(ls, "x") + + with pytest.raises(ValueError, match="bounds"): + _ = DragRect(ax, position=hi, width=span, snap_to=ls) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + dr = DragRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in dr.bounds) + assert dr.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_dragrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = DragRect(ax, position=data[2], width=(data[1] - data[2])) diff --git a/tests/test_flexrect.py b/tests/test_flexrect.py new file mode 100644 index 0000000..d1765c9 --- /dev/null +++ b/tests/test_flexrect.py @@ -0,0 +1,152 @@ +import typing as t + +import numpy as np +import pytest +from matplotlib.lines import Line2D +from pytest_mock import MockerFixture + +from matplotlib_window.base import FlexibleRect, get_native_data +from tests.conftest import PLOTOBJ_T +from tests.helpers import SNAP_TO_DATA_PARAMS, drag, has_callback_to + + +def test_flexrect_invalid_width_raises(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + with pytest.raises(ValueError, match="greater than"): + _ = FlexibleRect(ax=ax, position=0, width=0) + + +def test_flexrect_get_bounds(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = FlexibleRect(ax=ax, position=0, width=1) + assert dr.bounds == (0, 1) + + +def test_flexrect_limit_change_respans(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3) + + ax.set_ylim(5, 15) + + assert fr.face.myobj.get_xy() == pytest.approx((0.2, 5)) + assert fr.face.myobj.get_height() == pytest.approx(10) + + +def test_flexrect_disables_click(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + dr = FlexibleRect(ax=ax, position=0, width=1, allow_face_drag=False) + + parent_canvas = dr.face.parent_canvas + assert not has_callback_to(parent_canvas, "DragRect") + + +def test_flexrect_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + fr = FlexibleRect(ax=ax, position=0, width=1, redraw_callback=callback) + + ax.set_ylim(0, 10) # Limit change triggers a redraw + callback.assert_called() + assert fr.redraw_callback is callback + + +def test_flexrect_face_drag_disabled_by_default(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + assert fr.bounds == pytest.approx((0.2, 0.5)) + assert fr.face.bounds == pytest.approx((0.2, 0.5)) + + +def test_flexrect_face_drag_moves_edges(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + assert fr.bounds == pytest.approx((0.3, 0.6)) + assert fr.face.bounds == pytest.approx((0.3, 0.6)) + + +def test_flexrect_face_drag_after_edge_drag(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + # Widen using the right edge, then drag the face; the face should retain the new width & not + # jump back to its pre-resize location + drag(ax, (0.5, 0.5), (0.7, 0.5)) + assert fr.bounds == pytest.approx((0.2, 0.7)) + + drag(ax, (0.45, 0.5), (0.55, 0.5)) + assert fr.bounds == pytest.approx((0.3, 0.8)) + assert fr.face.bounds == pytest.approx((0.3, 0.8)) + + +def test_flexrect_face_drag_crossed_edges(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + fr = FlexibleRect(ax=ax, position=0.2, width=0.3, allow_face_drag=True) + + # Drag the left edge past the right edge, then drag the face + drag(ax, (0.2, 0.5), (0.7, 0.5)) + assert fr.bounds == pytest.approx((0.5, 0.7)) + + drag(ax, (0.6, 0.5), (0.5, 0.5)) + assert fr.bounds == pytest.approx((0.4, 0.6)) + assert fr.face.bounds == pytest.approx((0.4, 0.6)) + + +FACE_DRAG_SNAP_TO = Line2D(xdata=np.array([0, 0.5, 1]), ydata=np.array([10, 11, 10])) + + +def test_flexrect_face_drag_snapto_clamps_right(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.set_xlim(-1, 2) # Leave room to drag beyond the snap_to data while still inside the axes + fr = FlexibleRect( + ax=ax, position=0.2, width=0.3, snap_to=FACE_DRAG_SNAP_TO, allow_face_drag=True + ) + + drag(ax, (0.35, 0.5), (1.35, 0.5)) + assert fr.bounds == pytest.approx((0.7, 1.0)) + assert fr.face.bounds == pytest.approx((0.7, 1.0)) + + +def test_flexrect_face_drag_snapto_clamps_left(plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.set_xlim(-1, 2) # Leave room to drag beyond the snap_to data while still inside the axes + fr = FlexibleRect( + ax=ax, position=0.2, width=0.3, snap_to=FACE_DRAG_SNAP_TO, allow_face_drag=True + ) + + drag(ax, (0.35, 0.5), (-0.65, 0.5)) + assert fr.bounds == pytest.approx((0.0, 0.3)) + assert fr.face.bounds == pytest.approx((0.0, 0.3)) + + +def test_flexrect_face_drag_redraw_callback(plotobj: PLOTOBJ_T, mocker: MockerFixture) -> None: + _, ax = plotobj + callback = mocker.Mock() + _ = FlexibleRect(ax=ax, position=0.2, width=0.3, redraw_callback=callback, allow_face_drag=True) + + drag(ax, (0.35, 0.5), (0.45, 0.5)) + callback.assert_called() + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_position_width(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ls = ax.plot(data, range(5))[0] + native = get_native_data(ls, "x") + + fr = FlexibleRect(ax, position=data[1], width=(data[2] - data[1]), snap_to=ls) + assert all(isinstance(edge, float) for edge in fr.bounds) + assert fr.bounds == pytest.approx((native[1], native[2])) + assert fr.face.bounds == pytest.approx((native[1], native[2])) + + +@SNAP_TO_DATA_PARAMS +def test_flexrect_data_type_negative_width_raises(data: t.Any, plotobj: PLOTOBJ_T) -> None: + _, ax = plotobj + ax.plot(data, range(5)) + + with pytest.raises(ValueError, match="greater than"): + _ = FlexibleRect(ax, position=data[2], width=(data[1] - data[2])) diff --git a/tests/test_snapto.py b/tests/test_snapto.py deleted file mode 100644 index c45f9b4..0000000 --- a/tests/test_snapto.py +++ /dev/null @@ -1,98 +0,0 @@ -import numpy as np -import pytest -from matplotlib.lines import Line2D - -from matplotlib_window.base import DragLine, DragRect, Orientation, _DraggableObject -from tests.conftest import PLOTOBJ_T - - -def test_dragobj_snapto_none_passthrough() -> None: - do = _DraggableObject() - assert do.validate_snap_to(None) is None - - -def test_dragline_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=0) - assert dl.validate_snap_to(None) is None - - -def test_dragrect_snapto_none_passthrough(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - assert dr.validate_snap_to(None) is None - - -EMPTY_LINE = Line2D(xdata=np.array([]), ydata=np.array([])) - - -def test_dragobj_snapto_empty_data_raises() -> None: - do = _DraggableObject() - with pytest.raises(ValueError, match="empty"): - do.validate_snap_to(EMPTY_LINE) - - -def test_dragline_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax=ax, position=0) - with pytest.raises(ValueError, match="empty"): - dl.validate_snap_to(EMPTY_LINE) - - -def test_dragrect_snapto_empty_data_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=0, width=1) - with pytest.raises(ValueError, match="empty"): - dr.validate_snap_to(EMPTY_LINE) - - -DUMMY_LINE = Line2D(xdata=np.array([0, 1, 2]), ydata=np.array([0, 1, 2])) - - -def test_vertical_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=3, orientation=Orientation.VERTICAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -def test_horizontal_dragline_snapto_out_of_bounds_raises(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - dl = DragLine(ax, position=3, orientation=Orientation.HORIZONTAL) - - with pytest.raises(ValueError, match="bounds"): - dl.validate_snap_to(DUMMY_LINE) - - -def test_vertical_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragLine(ax, position=1, orientation=Orientation.VERTICAL, snap_to=DUMMY_LINE) - - -def test_horizontal_dragline_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragLine(ax, position=1, orientation=Orientation.HORIZONTAL, snap_to=DUMMY_LINE) - - -RECT_BOUNDS_TEST_CASES = ( - (-1, 1), # Left edge out - (0, 3), # Right edge out - (-1, 4), # Both edges out -) - - -@pytest.mark.parametrize(("position", "width"), RECT_BOUNDS_TEST_CASES) -def test_dragrect_snapto_out_of_bounds_raises( - position: int, width: int, plotobj: PLOTOBJ_T -) -> None: - _, ax = plotobj - dr = DragRect(ax=ax, position=position, width=width) - - with pytest.raises(ValueError, match="bounds"): - dr.validate_snap_to(DUMMY_LINE) - - -def test_dragrect_valid_snapto(plotobj: PLOTOBJ_T) -> None: - _, ax = plotobj - _ = DragRect(ax=ax, position=0, width=1, snap_to=DUMMY_LINE) diff --git a/tox.ini b/tox.ini index 57d5d0c..3f98b99 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ commands = python -m pytest deps = pytest pytest-cov + pytest-mock pytest-randomly [testenv:clean] diff --git a/uv.lock b/uv.lock index 475b02a..a880afa 100644 --- a/uv.lock +++ b/uv.lock @@ -982,6 +982,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, { name = "ruff" }, { name = "sco1-bumper" }, @@ -1003,6 +1004,7 @@ dev = [ { name = "pre-commit", specifier = "~=4.0" }, { name = "pytest", specifier = "~=9.0" }, { name = "pytest-cov", specifier = "~=7.0" }, + { name = "pytest-mock", specifier = "~=3.17" }, { name = "pytest-randomly", specifier = "~=5.0" }, { name = "ruff", specifier = "~=0.7" }, { name = "sco1-bumper", specifier = "~=2.0" }, @@ -1485,6 +1487,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz", hash = "sha256:e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4", size = 43064, upload-time = "2026-10-17T16:21:42.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl", hash = "sha256:1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b", size = 11248, upload-time = "2026-10-17T16:21:41.265Z" }, +] + [[package]] name = "pytest-randomly" version = "5.0.0"