From f4c01d06c9f00c442e25942b07023f21a9e06e6d Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 15 Aug 2021 10:25:49 +0100 Subject: [PATCH 01/14] Add method to add default state --- lib/matplotlib/tests/test_widgets.py | 46 +++++++++++++++++-- lib/matplotlib/widgets.py | 67 +++++++++++++++++++++++----- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 9955aad0512d..1ebdba43fdd7 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -171,6 +171,26 @@ def onselect(epress, erelease): assert tool.extents == (xdata_new, extents[1], ydata_new, extents[3]) +def test_rectangle_add_default_state(): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 70, 65, 125, 130) + + with pytest.raises(ValueError): + tool.add_default_state('unsupported_state') + + with pytest.raises(ValueError): + tool.add_default_state('clear') + tool.add_default_state('move') + tool.add_default_state('square') + tool.add_default_state('center') + + @pytest.mark.parametrize('use_default_state', [True, False]) def test_rectangle_resize_center(use_default_state): ax = get_ax() @@ -184,7 +204,7 @@ def onselect(epress, erelease): assert tool.extents == (70.0, 125.0, 65.0, 130.0) if use_default_state: - tool._default_state.add('center') + tool.add_default_state('center') use_key = None else: use_key = 'control' @@ -257,7 +277,7 @@ def onselect(epress, erelease): assert tool.extents == (70.0, 120.0, 65.0, 115.0) if use_default_state: - tool._default_state.add('square') + tool.add_default_state('square') use_key = None else: use_key = 'shift' @@ -326,8 +346,8 @@ def onselect(epress, erelease): tool = widgets.RectangleSelector(ax, onselect, interactive=True) # Create rectangle _resize_rectangle(tool, 70, 65, 120, 115) - tool._default_state.add('square') - tool._default_state.add('center') + tool.add_default_state('square') + tool.add_default_state('center') assert tool.extents == (70.0, 120.0, 65.0, 115.0) # resize NE handle @@ -803,6 +823,24 @@ def onselect(*args): assert tool.extents == (10, 50) +def test_span_selector_add_default_state(): + ax = get_ax() + + def onselect(*args): + pass + + tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=True) + + with pytest.raises(ValueError): + tool.add_default_state('unsupported_state') + with pytest.raises(ValueError): + tool.add_default_state('center') + with pytest.raises(ValueError): + tool.add_default_state('square') + + tool.add_default_state('move') + + def test_tool_line_handle(): ax = get_ax() diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 34e6d9a4a948..2d803af1332f 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2062,6 +2062,40 @@ def set_handle_props(self, **handle_props): self.update() self._handle_props.update(handle_props) + @property + def default_state(self): + """ + Default state of the selector, which affect the widget's behavior. See + the `state_modifier_keys` parameters for details. + """ + return tuple(self._default_state) + + def add_default_state(self, value): + """ + Add a default state to define the widget's behavior. See the + `state_modifier_keys` parameters for details. + + Parameters + ---------- + value : str + Must be a supported state of the selector. See the + `state_modifier_keys` parameters for details. + + Raises + ------ + ValueError + When the value is not supported by the selector. + + """ + supported_default_state = [ + key for key, value in self.state_modifier_keys.items() + if key != 'clear' and value != 'not-applicable' + ] + if value not in supported_default_state: + keys = ', '.join(supported_default_state) + raise ValueError('Setting default state must be one of the ' + f'following: {keys}.') + self._default_state.add(value) class SpanSelector(_SelectorWidget): """ @@ -2129,6 +2163,12 @@ def on_select(min: float, max: float) -> Any Distance in pixels within which the interactive tool handles can be activated. + state_modifier_keys : dict, optional + Keyboard modifiers which affect the widget's behavior. Values + amend the defaults. + + - "clear": Clear the current shape, default: "escape". + drag_from_anywhere : bool, default: False If `True`, the widget can be moved by clicking anywhere within its bounds. @@ -2157,9 +2197,15 @@ def on_select(min: float, max: float) -> Any def __init__(self, ax, onselect, direction, minspan=0, useblit=False, props=None, onmove_callback=None, interactive=False, button=None, handle_props=None, grab_range=10, - drag_from_anywhere=False, ignore_event_outside=False): + state_modifier_keys=None, drag_from_anywhere=False, + ignore_event_outside=False): - super().__init__(ax, onselect, useblit=useblit, button=button) + if state_modifier_keys is None: + state_modifier_keys = dict(clear='escape', + square='not-applicable', + center='not-applicable') + super().__init__(ax, onselect, useblit=useblit, button=button, + state_modifier_keys=state_modifier_keys) if props is None: props = dict(facecolor='red', alpha=0.5) @@ -2446,7 +2492,7 @@ def _set_active_handle(self, event): # Prioritise center handle over other handles # Use 'C' to match the notation used in the RectangleSelector - if 'move' in self._state: + if 'move' in self._state | self._default_state: self._active_handle = 'C' elif e_dist > self.grab_range: # Not close to any handles @@ -2730,8 +2776,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) - "move": Move the existing shape, default: no modifier. - "clear": Clear the current shape, default: "escape". - "square": Make the shape square, default: "shift". - - "center": Make the initial point the center of the shape, - default: "ctrl". + - "center": change the shape around its center, default: "ctrl". "square" and "center" can be combined. @@ -2893,7 +2938,6 @@ def _press(self, event): # button, ... if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) - self._extents_on_press = self.extents else: self._active_handle = None @@ -2910,6 +2954,8 @@ def _press(self, event): else: self.set_visible(True) + self._extents_on_press = self.extents + return False def _release(self, event): @@ -3027,9 +3073,7 @@ def _onmove(self, event): y1 = event.ydata # move existing shape - elif (self._active_handle == 'C' or - (self.drag_from_anywhere and self._contains(event)) and - self._extents_on_press is not None): + elif self._active_handle == 'C': x0, x1, y0, y1 = self._extents_on_press dx = event.xdata - self._eventpress.xdata dy = event.ydata - self._eventpress.ydata @@ -3164,14 +3208,13 @@ def _set_active_handle(self, event): e_idx, e_dist = self._edge_handles.closest(event.x, event.y) m_idx, m_dist = self._center_handle.closest(event.x, event.y) - if 'move' in self._state: + if 'move' in self._state | self._default_state: self._active_handle = 'C' # Set active handle as closest handle, if mouse click is close enough. elif m_dist < self.grab_range * 2: # Prioritise center handle over other handles self._active_handle = 'C' - elif (c_dist > self.grab_range and - e_dist > self.grab_range): + elif c_dist > self.grab_range and e_dist > self.grab_range: # Not close to any handles if self.drag_from_anywhere and self._contains(event): # Check if we've clicked inside the region From 3a1bde9ad6daa23fab32c9e64ce7b19a6a9ffcde Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sun, 15 Aug 2021 10:43:14 +0100 Subject: [PATCH 02/14] Privatize state_modifier_keys --- .../deprecations/20839-EP.rst | 4 ++++ lib/matplotlib/widgets.py | 23 ++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/20839-EP.rst diff --git a/doc/api/next_api_changes/deprecations/20839-EP.rst b/doc/api/next_api_changes/deprecations/20839-EP.rst new file mode 100644 index 000000000000..403edc2ef628 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/20839-EP.rst @@ -0,0 +1,4 @@ +Selector widget state internals +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +*state_modifier_keys* have to be defined when creating the selector widget. The +*state_modifier_keys* attribute is deprecated. diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 2d803af1332f..707b0bdf8ae6 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1809,9 +1809,9 @@ def __init__(self, ax, onselect, useblit=False, button=None, self.useblit = useblit and self.canvas.supports_blit self.connect_default_events() - self.state_modifier_keys = dict(move=' ', clear='escape', - square='shift', center='control') - self.state_modifier_keys.update(state_modifier_keys or {}) + self._state_modifier_keys = dict(move=' ', clear='escape', + square='shift', center='control') + self._state_modifier_keys.update(state_modifier_keys or {}) self.background = None @@ -1834,6 +1834,7 @@ def __init__(self, ax, onselect, useblit=False, button=None, eventpress = _api.deprecate_privatize_attribute("3.5") eventrelease = _api.deprecate_privatize_attribute("3.5") state = _api.deprecate_privatize_attribute("3.5") + state_modifier_keys = _api.deprecate_privatize_attribute("3.5") def set_active(self, active): super().set_active(active) @@ -1944,7 +1945,7 @@ def press(self, event): key = event.key or '' key = key.replace('ctrl', 'control') # move state is locked in on a button press - if key == self.state_modifier_keys['move']: + if key == self._state_modifier_keys['move']: self._state.add('move') self._press(event) return True @@ -1992,10 +1993,10 @@ def on_key_press(self, event): if self.active: key = event.key or '' key = key.replace('ctrl', 'control') - if key == self.state_modifier_keys['clear']: + if key == self._state_modifier_keys['clear']: self.clear() return - for (state, modifier) in self.state_modifier_keys.items(): + for (state, modifier) in self._state_modifier_keys.items(): if modifier in key: self._state.add(state) self._on_key_press(event) @@ -2007,7 +2008,7 @@ def on_key_release(self, event): """Key release event handler and validator.""" if self.active: key = event.key or '' - for (state, modifier) in self.state_modifier_keys.items(): + for (state, modifier) in self._state_modifier_keys.items(): if modifier in key: self._state.discard(state) self._on_key_release(event) @@ -2088,7 +2089,7 @@ def add_default_state(self, value): """ supported_default_state = [ - key for key, value in self.state_modifier_keys.items() + key for key, value in self._state_modifier_keys.items() if key != 'clear' and value != 'not-applicable' ] if value not in supported_default_state: @@ -3635,13 +3636,13 @@ def _on_key_release(self, event): # 'move_all' mode (by checking the released key) if (not self._selection_completed and - (event.key == self.state_modifier_keys.get('move_vertex') - or event.key == self.state_modifier_keys.get('move_all'))): + (event.key == self._state_modifier_keys.get('move_vertex') + or event.key == self._state_modifier_keys.get('move_all'))): self._xs.append(event.xdata) self._ys.append(event.ydata) self._draw_polygon() # Reset the polygon if the released key is the 'clear' key. - elif event.key == self.state_modifier_keys.get('clear'): + elif event.key == self._state_modifier_keys.get('clear'): event = self._clean_event(event) self._xs, self._ys = [event.xdata], [event.ydata] self._selection_completed = False From a6de72c7bff4a4859c9fcb0e3a887d5ec5ddabe4 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 15 Nov 2021 22:35:00 +0000 Subject: [PATCH 03/14] Take into account aspect ratio in square state --- lib/matplotlib/tests/test_widgets.py | 41 ++++++++- lib/matplotlib/widgets.py | 129 +++++++++++++++------------ 2 files changed, 113 insertions(+), 57 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 1ebdba43fdd7..7ee77852b303 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -189,6 +189,7 @@ def onselect(epress, erelease): tool.add_default_state('move') tool.add_default_state('square') tool.add_default_state('center') + tool.add_default_state('data_coordinates') @pytest.mark.parametrize('use_default_state', [True, False]) @@ -405,6 +406,42 @@ def onselect(epress, erelease): ydata_new, extents[3] - ydiff) +def test_rectangle_resize_square_center_aspect(): + ax = get_ax() + ax.set_aspect(0.8) + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect, interactive=True) + # Create rectangle + _resize_rectangle(tool, 70, 65, 120, 115) + tool.add_default_state('square') + tool.add_default_state('center') + assert tool.extents == (70.0, 120.0, 65.0, 115.0) + + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + 46.25, 133.75]) + + # use data coordinates + do_event(tool, 'on_key_press', key='d') + # resize E handle + extents = tool.extents + xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] + xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 + xdata_new, ydata_new = xdata + xdiff, ydata + ychange = width / 2 + xdiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + ycenter - ychange, ycenter + ychange]) + + def test_ellipse(): """For ellipse, test out the key modifiers""" ax = get_ax() @@ -443,7 +480,7 @@ def onselect(epress, erelease): do_event(tool, 'on_key_release', xdata=10, ydata=10, button=1, key='shift') extents = [int(e) for e in tool.extents] - assert extents == [10, 35, 10, 34] + assert extents == [10, 35, 10, 35] # create a square from center do_event(tool, 'on_key_press', xdata=100, ydata=100, button=1, @@ -454,7 +491,7 @@ def onselect(epress, erelease): do_event(tool, 'on_key_release', xdata=100, ydata=100, button=1, key='ctrl+shift') extents = [int(e) for e in tool.extents] - assert extents == [70, 129, 70, 130] + assert extents == [70, 130, 70, 130] assert tool.geometry.shape == (2, 73) assert_allclose(tool.geometry[:, 0], [70., 100]) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 707b0bdf8ae6..c1493d017304 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1810,7 +1810,8 @@ def __init__(self, ax, onselect, useblit=False, button=None, self.connect_default_events() self._state_modifier_keys = dict(move=' ', clear='escape', - square='shift', center='control') + square='shift', center='control', + data_coordinates='d') self._state_modifier_keys.update(state_modifier_keys or {}) self.background = None @@ -1996,6 +1997,12 @@ def on_key_press(self, event): if key == self._state_modifier_keys['clear']: self.clear() return + if key == 'd' and key in self._state_modifier_keys.values(): + modifier = 'data_coordinates' + if modifier in self._default_state: + self._default_state.remove(modifier) + else: + self.add_default_state(modifier) for (state, modifier) in self._state_modifier_keys.items(): if modifier in key: self._state.add(state) @@ -2204,7 +2211,8 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False, if state_modifier_keys is None: state_modifier_keys = dict(clear='escape', square='not-applicable', - center='not-applicable') + center='not-applicable', + data_coordinates='not-applicable') super().__init__(ax, onselect, useblit=useblit, button=button, state_modifier_keys=state_modifier_keys) @@ -2778,8 +2786,12 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) - "clear": Clear the current shape, default: "escape". - "square": Make the shape square, default: "shift". - "center": change the shape around its center, default: "ctrl". + - "data_coordinates": define if data or figure coordinates should be + used to define the square shape, default: "d" - "square" and "center" can be combined. + "square" and "center" can be combined. The square shape can be defined + in data or figure coordinates as determined by the ``data_coordinates`` + modifier, which can be enable and disable by pressing the 'd' key. drag_from_anywhere : bool, default: False If `True`, the widget can be moved by clicking anywhere within @@ -3014,64 +3026,75 @@ def _onmove(self, event): """Motion notify event handler.""" state = self._state | self._default_state + + dx = event.xdata - self._eventpress.xdata + dy = event.ydata - self._eventpress.ydata + refmax = None + if 'data_coordinates' in state: + aspect_ratio = 1 + refx, refy = dx, dy + else: + figure_size = self.ax.get_figure().get_size_inches() + ll, ur = self.ax.get_position() * figure_size + width, height = ur - ll + aspect_ratio = height / width * self.ax.get_data_ratio() + refx = event.xdata / (self._eventpress.xdata + 1e-6) + refy = event.ydata / (self._eventpress.ydata + 1e-6) + # resize an existing shape if self._active_handle and self._active_handle != 'C': x0, x1, y0, y1 = self._extents_on_press size_on_press = [x1 - x0, y1 - y0] center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata - - # change sign of relative changes to simplify calculation - # Switch variables so that only x1 and/or y1 are updated on move - x_factor = y_factor = 1 - if 'W' in self._active_handle: - x_factor *= -1 - dx *= x_factor - x0 = x1 - if 'S' in self._active_handle: - y_factor *= -1 - dy *= y_factor - y0 = y1 # Keeping the center fixed if 'center' in state: if 'square' in state: - # Force the same change in dx and dy - if self._active_handle in ['E', 'W']: - # using E, W handle we need to update dy accordingly - dy = dx - elif self._active_handle in ['S', 'N']: - # using S, N handle, we need to update dx accordingly - dx = dy + # when using a corner, find which reference to use + if self._active_handle in self._corner_order: + refmax = max(refx, refy, key=abs) + if self._active_handle in ['E', 'W'] or refmax == refx: + hw = event.xdata - center[0] + hh = hw / aspect_ratio else: - dx = dy = max(dx, dy, key=abs) - - # new half-width and half-height - hw = size_on_press[0] / 2 + dx - hh = size_on_press[1] / 2 + dy - - if 'square' not in state: + hh = event.ydata - center[1] + hw = hh * aspect_ratio + else: + hw = size_on_press[0] / 2 + hh = size_on_press[1] / 2 # cancel changes in perpendicular direction - if self._active_handle in ['E', 'W']: - hh = size_on_press[1] / 2 - if self._active_handle in ['N', 'S']: - hw = size_on_press[0] / 2 + if self._active_handle in ['E', 'W'] + self._corner_order: + hw = abs(event.xdata - center[0]) + if self._active_handle in ['N', 'S'] + self._corner_order: + hh = abs(event.ydata - center[1]) x0, x1, y0, y1 = (center[0] - hw, center[0] + hw, center[1] - hh, center[1] + hh) else: - # Keeping the opposite corner/edge fixed + # change sign of relative changes to simplify calculation + # Switch variables so that x1 and/or y1 are updated on move + x_factor = y_factor = 1 + if 'W' in self._active_handle: + x0 = x1 + x_factor *= -1 + if 'S' in self._active_handle: + y0 = y1 + y_factor *= -1 + if self._active_handle in ['E', 'W'] + self._corner_order: + x1 = event.xdata + if self._active_handle in ['N', 'S'] + self._corner_order: + y1 = event.ydata if 'square' in state: - dx = dy = max(dx, dy, key=abs) - x1 = x0 + x_factor * (dx + size_on_press[0]) - y1 = y0 + y_factor * (dy + size_on_press[1]) - else: - if self._active_handle in ['E', 'W'] + self._corner_order: - x1 = event.xdata - if self._active_handle in ['N', 'S'] + self._corner_order: - y1 = event.ydata + # when using a corner, find which reference to use + if self._active_handle in self._corner_order: + refmax = max(refx, refy, key=abs) + if self._active_handle in ['E', 'W'] or refmax == refx: + sign = np.sign(event.ydata - y0) + y1 = y0 + sign * abs(x1 - x0) / aspect_ratio + else: + sign = np.sign(event.xdata - x0) + x1 = x0 + sign * abs(y1 - y0) * aspect_ratio # move existing shape elif self._active_handle == 'C': @@ -3090,21 +3113,16 @@ def _onmove(self, event): if self.ignore_event_outside and self._selection_completed: return center = [self._eventpress.xdata, self._eventpress.ydata] - center_pix = [self._eventpress.x, self._eventpress.y] dx = (event.xdata - center[0]) / 2. dy = (event.ydata - center[1]) / 2. # square shape if 'square' in state: - dx_pix = abs(event.x - center_pix[0]) - dy_pix = abs(event.y - center_pix[1]) - if not dx_pix: - return - maxd = max(abs(dx_pix), abs(dy_pix)) - if abs(dx_pix) < maxd: - dx *= maxd / (abs(dx_pix) + 1e-6) - if abs(dy_pix) < maxd: - dy *= maxd / (abs(dy_pix) + 1e-6) + refmax = max(refx, refy, key=abs) + if refmax == refx: + dy = dx / aspect_ratio + else: + dx = dy * aspect_ratio # from center if 'center' in state: @@ -3464,7 +3482,8 @@ def __init__(self, ax, onselect, useblit=False, state_modifier_keys = dict(clear='escape', move_vertex='control', move_all='shift', move='not-applicable', square='not-applicable', - center='not-applicable') + center='not-applicable', + data_coordinates='not-applicable') super().__init__(ax, onselect, useblit=useblit, state_modifier_keys=state_modifier_keys) From c7484012c4a31fd6dcd4710239ae8a4703c3d234 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 17 Aug 2021 16:44:32 +0100 Subject: [PATCH 04/14] Implement rotation selector --- lib/matplotlib/patches.py | 18 +++++- lib/matplotlib/tests/test_widgets.py | 7 +-- lib/matplotlib/widgets.py | 94 +++++++++++++++++++--------- 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index c13bb24cc201..29e0861b7599 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -706,7 +706,8 @@ def __str__(self): return fmt % pars @docstring.dedent_interpd - def __init__(self, xy, width, height, angle=0.0, **kwargs): + def __init__(self, xy, width, height, angle=0.0, + rotate_around_center=False, **kwargs): """ Parameters ---------- @@ -717,7 +718,12 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs): height : float Rectangle height. angle : float, default: 0 - Rotation in degrees anti-clockwise about *xy*. + Rotation in degrees anti-clockwise about *xy* if + *rotate_around_center* if False, otherwise rotate around the + center of the rectangle + rotate_around_center : bool, default: False + If True, the rotation is performed around the center of the + rectangle. Other Parameters ---------------- @@ -730,6 +736,7 @@ def __init__(self, xy, width, height, angle=0.0, **kwargs): self._width = width self._height = height self.angle = float(angle) + self.rotate_around_center = rotate_around_center self._convert_units() # Validate the inputs. def get_path(self): @@ -750,9 +757,14 @@ def get_patch_transform(self): # important to call the accessor method and not directly access the # transformation member variable. bbox = self.get_bbox() + if self.rotate_around_center: + width, height = bbox.x1 - bbox.x0, bbox.y1 - bbox.y0 + rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2. + else: + rotation_point = bbox.x0, bbox.y0 return (transforms.BboxTransformTo(bbox) + transforms.Affine2D().rotate_deg_around( - bbox.x0, bbox.y0, self.angle)) + *rotation_point, self.angle)) def get_x(self): """Return the left coordinate of the rectangle.""" diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 7ee77852b303..e616a01fad15 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -510,11 +510,10 @@ def onselect(epress, erelease): 'markeredgecolor': 'b'}) tool.extents = (100, 150, 100, 150) - assert tool.corners == ( - (100, 150, 150, 100), (100, 100, 150, 150)) + assert_allclose(tool.corners, ((100, 150, 150, 100), (100, 100, 150, 150))) assert tool.extents == (100, 150, 100, 150) - assert tool.edge_centers == ( - (100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150)) + assert_allclose(tool.edge_centers, + ((100, 125.0, 150, 125.0), (125.0, 100, 125.0, 150))) assert tool.extents == (100, 150, 100, 150) # grab a corner and move it diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index c1493d017304..20b76cd2e7fa 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -20,7 +20,7 @@ from . import _api, backend_tools, cbook, colors, ticker from .lines import Line2D from .patches import Circle, Rectangle, Ellipse -from .transforms import TransformedPatchPath +from .transforms import TransformedPatchPath, Affine2D class LockDraw: @@ -1811,7 +1811,8 @@ def __init__(self, ax, onselect, useblit=False, button=None, self._state_modifier_keys = dict(move=' ', clear='escape', square='shift', center='control', - data_coordinates='d') + data_coordinates='d', + rotate='r') self._state_modifier_keys.update(state_modifier_keys or {}) self.background = None @@ -1946,8 +1947,9 @@ def press(self, event): key = event.key or '' key = key.replace('ctrl', 'control') # move state is locked in on a button press - if key == self._state_modifier_keys['move']: - self._state.add('move') + for action in ['move']: + if key == self._state_modifier_keys[action]: + self._state.add(action) self._press(event) return True return False @@ -1997,14 +1999,15 @@ def on_key_press(self, event): if key == self._state_modifier_keys['clear']: self.clear() return - if key == 'd' and key in self._state_modifier_keys.values(): - modifier = 'data_coordinates' - if modifier in self._default_state: - self._default_state.remove(modifier) - else: - self.add_default_state(modifier) + for state in ['rotate', 'data_coordinates']: + if key == self._state_modifier_keys[state]: + if state in self._default_state: + self._default_state.remove(state) + else: + self.add_default_state(state) for (state, modifier) in self._state_modifier_keys.items(): - if modifier in key: + # Multiple keys are string concatenated using '+' + if modifier in key.split('+'): self._state.add(state) self._on_key_press(event) @@ -2212,7 +2215,8 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False, state_modifier_keys = dict(clear='escape', square='not-applicable', center='not-applicable', - data_coordinates='not-applicable') + data_coordinates='not-applicable', + rotate='not-applicable') super().__init__(ax, onselect, useblit=useblit, button=button, state_modifier_keys=state_modifier_keys) @@ -2788,6 +2792,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) - "center": change the shape around its center, default: "ctrl". - "data_coordinates": define if data or figure coordinates should be used to define the square shape, default: "d" + - "rotate": Rotate the shape around its center, default: "r". "square" and "center" can be combined. The square shape can be defined in data or figure coordinates as determined by the ``data_coordinates`` @@ -2835,8 +2840,6 @@ class RectangleSelector(_SelectorWidget): See also: :doc:`/gallery/widgets/rectangle_selector` """ - _shape_klass = Rectangle - @_api.rename_parameter("3.5", "maxdist", "grab_range") @_api.rename_parameter("3.5", "marker_props", "handle_props") @_api.rename_parameter("3.5", "rectprops", "props") @@ -2855,6 +2858,7 @@ def __init__(self, ax, onselect, drawtype='box', self._interactive = interactive self.drag_from_anywhere = drag_from_anywhere self.ignore_event_outside = ignore_event_outside + self._rotation = 0 if drawtype == 'none': # draw a line but make it invisible _api.warn_deprecated( @@ -2872,8 +2876,7 @@ def __init__(self, ax, onselect, drawtype='box', props['animated'] = self.useblit self.visible = props.pop('visible', self.visible) self._props = props - to_draw = self._shape_klass((0, 0), 0, 1, visible=False, - **self._props) + to_draw = self._init_shape(**self._props) self.ax.add_patch(to_draw) if drawtype == 'line': _api.warn_deprecated( @@ -2945,6 +2948,10 @@ def _handles_artists(self): return (*self._center_handle.artists, *self._corner_handles.artists, *self._edge_handles.artists) + def _init_shape(self, **props): + return Rectangle((0, 0), 0, 1, visible=False, + rotate_around_center=True, **props) + def _press(self, event): """Button press event handler.""" # make the drawn box/line visible get the click-coordinates, @@ -3041,9 +3048,17 @@ def _onmove(self, event): refx = event.xdata / (self._eventpress.xdata + 1e-6) refy = event.ydata / (self._eventpress.ydata + 1e-6) + + x0, x1, y0, y1 = self._extents_on_press # resize an existing shape - if self._active_handle and self._active_handle != 'C': - x0, x1, y0, y1 = self._extents_on_press + if 'rotate' in state and self._active_handle in self._corner_order: + # calculate angle abc + a = np.array([self._eventpress.xdata, self._eventpress.ydata]) + b = np.array(self.center) + c = np.array([event.xdata, event.ydata]) + self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) - + np.arctan2(a[1]-b[1], a[0]-b[0])) + elif self._active_handle and self._active_handle != 'C': size_on_press = [x1 - x0, y1 - y0] center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] @@ -3108,6 +3123,7 @@ def _onmove(self, event): # new shape else: + self._rotation = 0 # Don't create a new rectangle if there is already one when # ignore_event_outside=True if self.ignore_event_outside and self._selection_completed: @@ -3142,24 +3158,25 @@ def _onmove(self, event): @property def _rect_bbox(self): if self._drawtype == 'box': - x0 = self._selection_artist.get_x() - y0 = self._selection_artist.get_y() - width = self._selection_artist.get_width() - height = self._selection_artist.get_height() - return x0, y0, width, height + return self._selection_artist.get_bbox().bounds else: x, y = self._selection_artist.get_data() x0, x1 = min(x), max(x) y0, y1 = min(y), max(y) return x0, y0, x1 - x0, y1 - y0 + def _get_rotation_transform(self): + return Affine2D().rotate_around(*self.center, self._rotation) + @property def corners(self): """Corners of rectangle from lower left, moving clockwise.""" x0, y0, width, height = self._rect_bbox xc = x0, x0 + width, x0 + width, x0 yc = y0, y0, y0 + height, y0 + height - return xc, yc + transform = self._get_rotation_transform() + coords = transform.transform(np.array([xc, yc]).T).T + return coords[0], coords[1] @property def edge_centers(self): @@ -3169,7 +3186,9 @@ def edge_centers(self): h = height / 2. xe = x0, x0 + w, x0 + width, x0 + w ye = y0 + h, y0, y0 + h, y0 + height - return xe, ye + transform = self._get_rotation_transform() + coords = transform.transform(np.array([xe, ye]).T).T + return coords[0], coords[1] @property def center(self): @@ -3179,7 +3198,10 @@ def center(self): @property def extents(self): - """Return (xmin, xmax, ymin, ymax).""" + """ + Return (xmin, xmax, ymin, ymax) as defined by the bounding box before + rotation. + """ x0, y0, width, height = self._rect_bbox xmin, xmax = sorted([x0, x0 + width]) ymin, ymax = sorted([y0, y0 + height]) @@ -3197,6 +3219,17 @@ def extents(self, extents): self.set_visible(self.visible) self.update() + @property + def rotation(self): + """Rotation in degree.""" + return np.rad2deg(self._rotation) + + @rotation.setter + def rotation(self, value): + self._rotation = np.deg2rad(value) + # call extents setter to draw shape and update handles positions + self.extents = self.extents + draw_shape = _api.deprecate_privatize_attribute('3.5') def _draw_shape(self, extents): @@ -3216,6 +3249,7 @@ def _draw_shape(self, extents): self._selection_artist.set_y(ymin) self._selection_artist.set_width(xmax - xmin) self._selection_artist.set_height(ymax - ymin) + self._selection_artist.set_angle(self.rotation) elif self._drawtype == 'line': self._selection_artist.set_data([xmin, xmax], [ymin, ymax]) @@ -3288,9 +3322,11 @@ class EllipseSelector(RectangleSelector): :doc:`/gallery/widgets/rectangle_selector` """ - _shape_klass = Ellipse draw_shape = _api.deprecate_privatize_attribute('3.5') + def _init_shape(self, **props): + return Ellipse((0, 0), 0, 1, visible=False, **props) + def _draw_shape(self, extents): x0, x1, y0, y1 = extents xmin, xmax = sorted([x0, x1]) @@ -3303,6 +3339,7 @@ def _draw_shape(self, extents): self._selection_artist.center = center self._selection_artist.width = 2 * a self._selection_artist.height = 2 * b + self._selection_artist.set_angle(self.rotation) else: rad = np.deg2rad(np.arange(31) * 12) x = a * np.cos(rad) + center[0] @@ -3483,7 +3520,8 @@ def __init__(self, ax, onselect, useblit=False, move_all='shift', move='not-applicable', square='not-applicable', center='not-applicable', - data_coordinates='not-applicable') + data_coordinates='not-applicable', + rotate='not-applicable') super().__init__(ax, onselect, useblit=useblit, state_modifier_keys=state_modifier_keys) From 928e6625cb33433c908694328d705e1dd2778b65 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 17 Aug 2021 19:01:29 +0100 Subject: [PATCH 05/14] Apply inverse transformation to event so that the onmove calculation are correct --- lib/matplotlib/tests/test_widgets.py | 37 +++++++++++++++++ lib/matplotlib/widgets.py | 59 +++++++++++++++++----------- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index e616a01fad15..df87f9577be8 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -5,6 +5,7 @@ from matplotlib.testing.decorators import check_figures_equal, image_comparison from matplotlib.testing.widgets import do_event, get_ax, mock_event +import numpy as np from numpy.testing import assert_allclose import pytest @@ -406,6 +407,42 @@ def onselect(epress, erelease): ydata_new, extents[3] - ydiff) +@pytest.mark.parametrize('selector_class', + [widgets.RectangleSelector, widgets.EllipseSelector]) +def test_rectangle_rotate(selector_class): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = selector_class(ax, onselect=onselect, interactive=True) + # Draw rectangle + do_event(tool, 'press', xdata=100, ydata=100) + do_event(tool, 'onmove', xdata=130, ydata=140) + do_event(tool, 'release', xdata=130, ydata=140) + assert tool.extents == (100, 130, 100, 140) + + # Rotate anticlockwise using top-right corner + do_event(tool, 'on_key_press', key='r') + do_event(tool, 'press', xdata=130, ydata=140) + do_event(tool, 'onmove', xdata=110, ydata=145) + do_event(tool, 'release', xdata=110, ydata=145) + do_event(tool, 'on_key_press', key='r') + # Extents shouldn't change (as shape of rectangle hasn't changed) + assert tool.extents == (100, 130, 100, 140) + # Corners should move + # The third corner is at (100, 145) + assert_allclose(tool.corners, + np.array([[119.9, 139.9, 110.1, 90.1], + [95.4, 117.8, 144.5, 122.2]]), atol=0.1) + + # Scale using top-right corner + do_event(tool, 'press', xdata=110, ydata=145) + do_event(tool, 'onmove', xdata=110, ydata=160) + do_event(tool, 'release', xdata=110, ydata=160) + assert_allclose(tool.extents, (100, 141.5, 100, 150.4), atol=0.1) + + def test_rectangle_resize_square_center_aspect(): ax = get_ax() ax.set_aspect(0.8) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 20b76cd2e7fa..bdab32c42a47 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1947,9 +1947,9 @@ def press(self, event): key = event.key or '' key = key.replace('ctrl', 'control') # move state is locked in on a button press - for action in ['move']: - if key == self._state_modifier_keys[action]: - self._state.add(action) + for state in ['move']: + if key == self._state_modifier_keys[state]: + self._state.add(state) self._press(event) return True return False @@ -1999,16 +1999,15 @@ def on_key_press(self, event): if key == self._state_modifier_keys['clear']: self.clear() return - for state in ['rotate', 'data_coordinates']: - if key == self._state_modifier_keys[state]: - if state in self._default_state: - self._default_state.remove(state) - else: - self.add_default_state(state) for (state, modifier) in self._state_modifier_keys.items(): - # Multiple keys are string concatenated using '+' if modifier in key.split('+'): - self._state.add(state) + # rotate and data_coordinates are enable/disable + # on key press + if (state in ['rotate', 'data_coordinates'] and + state in self._state): + self._state.discard(state) + else: + self._state.add(state) self._on_key_press(event) def _on_key_press(self, event): @@ -2019,7 +2018,8 @@ def on_key_release(self, event): if self.active: key = event.key or '' for (state, modifier) in self._state_modifier_keys.items(): - if modifier in key: + if (modifier in key.split('+') and + state not in ['rotate', 'data_coordinates']): self._state.discard(state) self._on_key_release(event) @@ -3033,9 +3033,21 @@ def _onmove(self, event): """Motion notify event handler.""" state = self._state | self._default_state + rotate = ('rotate' in state and + self._active_handle in self._corner_order) + eventpress = self._eventpress + # The calculations are done for rotation at zero: we apply inverse + # transformation to events except when we rotate and move + if not (self._active_handle == 'C' or rotate): + inv_tr = self._get_rotation_transform().inverted() + event.xdata, event.ydata = inv_tr.transform( + [event.xdata, event.ydata]) + eventpress.xdata, eventpress.ydata = inv_tr.transform( + [eventpress.xdata, eventpress.ydata] + ) - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata + dx = event.xdata - eventpress.xdata + dy = event.ydata - eventpress.ydata refmax = None if 'data_coordinates' in state: aspect_ratio = 1 @@ -3045,19 +3057,20 @@ def _onmove(self, event): ll, ur = self.ax.get_position() * figure_size width, height = ur - ll aspect_ratio = height / width * self.ax.get_data_ratio() - refx = event.xdata / (self._eventpress.xdata + 1e-6) - refy = event.ydata / (self._eventpress.ydata + 1e-6) - + refx = event.xdata / (eventpress.xdata + 1e-6) + refy = event.ydata / (eventpress.ydata + 1e-6) x0, x1, y0, y1 = self._extents_on_press - # resize an existing shape - if 'rotate' in state and self._active_handle in self._corner_order: + # rotate an existing shape + if rotate: # calculate angle abc - a = np.array([self._eventpress.xdata, self._eventpress.ydata]) + a = np.array([eventpress.xdata, eventpress.ydata]) b = np.array(self.center) c = np.array([event.xdata, event.ydata]) self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) - np.arctan2(a[1]-b[1], a[0]-b[0])) + + # resize an existing shape elif self._active_handle and self._active_handle != 'C': size_on_press = [x1 - x0, y1 - y0] center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2] @@ -3114,8 +3127,8 @@ def _onmove(self, event): # move existing shape elif self._active_handle == 'C': x0, x1, y0, y1 = self._extents_on_press - dx = event.xdata - self._eventpress.xdata - dy = event.ydata - self._eventpress.ydata + dx = event.xdata - eventpress.xdata + dy = event.ydata - eventpress.ydata x0 += dx x1 += dx y0 += dy @@ -3128,7 +3141,7 @@ def _onmove(self, event): # ignore_event_outside=True if self.ignore_event_outside and self._selection_completed: return - center = [self._eventpress.xdata, self._eventpress.ydata] + center = [eventpress.xdata, eventpress.ydata] dx = (event.xdata - center[0]) / 2. dy = (event.ydata - center[1]) / 2. From 9349727851ffe30753ea5714c8d6d6b29d9b986e Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 18 Aug 2021 14:24:57 +0100 Subject: [PATCH 06/14] Fix shape rectangle when using rotation and the axes aspect ratio != 1 --- lib/matplotlib/axes/_axes.py | 7 ++ lib/matplotlib/patches.py | 17 +++-- lib/matplotlib/tests/test_widgets.py | 46 +++++++------ lib/matplotlib/widgets.py | 97 ++++++++++++++++++---------- 4 files changed, 112 insertions(+), 55 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 6f6db320fea7..bf9b2ce23aef 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -8094,3 +8094,10 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5, tricontourf = mtri.tricontourf tripcolor = mtri.tripcolor triplot = mtri.triplot + + def _get_aspect_ratio(self): + """Convenience method to calculate aspect ratio of the axes.""" + figure_size = self.get_figure().get_size_inches() + ll, ur = self.get_position() * figure_size + width, height = ur - ll + return height / (width * self.get_data_ratio()) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 29e0861b7599..c90f3dff2531 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -737,6 +737,8 @@ def __init__(self, xy, width, height, angle=0.0, self._height = height self.angle = float(angle) self.rotate_around_center = rotate_around_center + # Required for RectangleSelector with axes aspect ratio != 1 + self._aspect_ratio_correction = 1.0 self._convert_units() # Validate the inputs. def get_path(self): @@ -762,9 +764,13 @@ def get_patch_transform(self): rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2. else: rotation_point = bbox.x0, bbox.y0 - return (transforms.BboxTransformTo(bbox) - + transforms.Affine2D().rotate_deg_around( - *rotation_point, self.angle)) + return transforms.BboxTransformTo(bbox) \ + + transforms.Affine2D() \ + .translate(-rotation_point[0], -rotation_point[1]) \ + .scale(1, self._aspect_ratio_correction) \ + .rotate_deg(self.angle) \ + .scale(1, 1 / self._aspect_ratio_correction) \ + .translate(*rotation_point) def get_x(self): """Return the left coordinate of the rectangle.""" @@ -1523,6 +1529,8 @@ def __init__(self, xy, width, height, angle=0, **kwargs): self._width, self._height = width, height self._angle = angle self._path = Path.unit_circle() + # Required for EllipseSelector with axes aspect ratio != 1 + self._aspect_ratio_correction = 1.0 # Note: This cannot be calculated until this is added to an Axes self._patch_transform = transforms.IdentityTransform() @@ -1540,8 +1548,9 @@ def _recompute_transform(self): width = self.convert_xunits(self._width) height = self.convert_yunits(self._height) self._patch_transform = transforms.Affine2D() \ - .scale(width * 0.5, height * 0.5) \ + .scale(width * 0.5, height * 0.5 * self._aspect_ratio_correction) \ .rotate_deg(self.angle) \ + .scale(1, 1 / self._aspect_ratio_correction) \ .translate(*center) def get_path(self): diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index df87f9577be8..08e118403ef1 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -350,7 +350,7 @@ def onselect(epress, erelease): _resize_rectangle(tool, 70, 65, 120, 115) tool.add_default_state('square') tool.add_default_state('center') - assert tool.extents == (70.0, 120.0, 65.0, 115.0) + assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) # resize NE handle extents = tool.extents @@ -358,8 +358,8 @@ def onselect(epress, erelease): xdiff, ydiff = 10, 5 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize E handle extents = tool.extents @@ -367,8 +367,8 @@ def onselect(epress, erelease): xdiff = 10 xdata_new, ydata_new = xdata + xdiff, ydata _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize E handle negative diff extents = tool.extents @@ -376,8 +376,8 @@ def onselect(epress, erelease): xdiff = -20 xdata_new, ydata_new = xdata + xdiff, ydata _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] - xdiff, xdata_new, - extents[2] - xdiff, extents[3] + xdiff) + assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new, + extents[2] - xdiff, extents[3] + xdiff)) # resize W handle extents = tool.extents @@ -385,8 +385,8 @@ def onselect(epress, erelease): xdiff = 5 xdata_new, ydata_new = xdata + xdiff, ydata _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2] + xdiff, extents[3] - xdiff) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff)) # resize W handle negative diff extents = tool.extents @@ -394,8 +394,8 @@ def onselect(epress, erelease): xdiff = -25 xdata_new, ydata_new = xdata + xdiff, ydata _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (xdata_new, extents[1] - xdiff, - extents[2] + xdiff, extents[3] - xdiff) + assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff, + extents[2] + xdiff, extents[3] - xdiff)) # resize SW handle extents = tool.extents @@ -403,8 +403,8 @@ def onselect(epress, erelease): xdiff, ydiff = 20, 25 xdata_new, ydata_new = xdata + xdiff, ydata + ydiff _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert tool.extents == (extents[0] + ydiff, extents[1] - ydiff, - ydata_new, extents[3] - ydiff) + assert_allclose(tool.extents, (extents[0] + ydiff, extents[1] - ydiff, + ydata_new, extents[3] - ydiff)) @pytest.mark.parametrize('selector_class', @@ -421,26 +421,35 @@ def onselect(epress, erelease): do_event(tool, 'onmove', xdata=130, ydata=140) do_event(tool, 'release', xdata=130, ydata=140) assert tool.extents == (100, 130, 100, 140) + assert len(tool._default_state) == 0 + assert len(tool._state) == 0 # Rotate anticlockwise using top-right corner do_event(tool, 'on_key_press', key='r') + assert tool._default_state == set(['rotate']) + assert len(tool._state) == 0 do_event(tool, 'press', xdata=130, ydata=140) - do_event(tool, 'onmove', xdata=110, ydata=145) - do_event(tool, 'release', xdata=110, ydata=145) + do_event(tool, 'onmove', xdata=120, ydata=145) + do_event(tool, 'release', xdata=120, ydata=145) do_event(tool, 'on_key_press', key='r') + assert len(tool._default_state) == 0 + assert len(tool._state) == 0 # Extents shouldn't change (as shape of rectangle hasn't changed) assert tool.extents == (100, 130, 100, 140) + assert_allclose(tool.rotation, 25.56, atol=0.01) + tool.rotation = 45 + assert tool.rotation == 45 # Corners should move # The third corner is at (100, 145) assert_allclose(tool.corners, - np.array([[119.9, 139.9, 110.1, 90.1], - [95.4, 117.8, 144.5, 122.2]]), atol=0.1) + np.array([[118.53, 139.75, 111.46, 90.25], + [95.25, 116.46, 144.75, 123.54]]), atol=0.01) # Scale using top-right corner do_event(tool, 'press', xdata=110, ydata=145) do_event(tool, 'onmove', xdata=110, ydata=160) do_event(tool, 'release', xdata=110, ydata=160) - assert_allclose(tool.extents, (100, 141.5, 100, 150.4), atol=0.1) + assert_allclose(tool.extents, (100, 139.75, 100, 151.82), atol=0.01) def test_rectangle_resize_square_center_aspect(): @@ -462,6 +471,7 @@ def onselect(epress, erelease): xdata, ydata = extents[1], extents[3] xdiff = 10 xdata_new, ydata_new = xdata + xdiff, ydata + ychange = xdiff * 1 / tool._aspect_ratio_correction _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, 46.25, 133.75]) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index bdab32c42a47..6ccb7b692e36 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1947,9 +1947,8 @@ def press(self, event): key = event.key or '' key = key.replace('ctrl', 'control') # move state is locked in on a button press - for state in ['move']: - if key == self._state_modifier_keys[state]: - self._state.add(state) + if key == self._state_modifier_keys['move']: + self._state.add('move') self._press(event) return True return False @@ -2000,14 +1999,10 @@ def on_key_press(self, event): self.clear() return for (state, modifier) in self._state_modifier_keys.items(): - if modifier in key.split('+'): - # rotate and data_coordinates are enable/disable - # on key press - if (state in ['rotate', 'data_coordinates'] and - state in self._state): - self._state.discard(state) - else: - self._state.add(state) + # 'rotate' and 'data_coordinates' are added in _default_state + if (modifier in key.split('+') and + state not in ['rotate', 'data_coordinates']): + self._state.add(state) self._on_key_press(event) def _on_key_press(self, event): @@ -2017,9 +2012,9 @@ def on_key_release(self, event): """Key release event handler and validator.""" if self.active: key = event.key or '' + key = key.replace('ctrl', 'control') for (state, modifier) in self._state_modifier_keys.items(): - if (modifier in key.split('+') and - state not in ['rotate', 'data_coordinates']): + if modifier in key.split('+'): self._state.discard(state) self._on_key_release(event) @@ -2858,7 +2853,8 @@ def __init__(self, ax, onselect, drawtype='box', self._interactive = interactive self.drag_from_anywhere = drag_from_anywhere self.ignore_event_outside = ignore_event_outside - self._rotation = 0 + self._rotation = 0.0 + self._aspect_ratio_correction = 1.0 if drawtype == 'none': # draw a line but make it invisible _api.warn_deprecated( @@ -2892,6 +2888,7 @@ def __init__(self, ax, onselect, drawtype='box', self.ax.add_line(to_draw) self._selection_artist = to_draw + self._set_aspect_ratio_correction() self.minspanx = minspanx self.minspany = minspany @@ -2975,6 +2972,8 @@ def _press(self, event): self.set_visible(True) self._extents_on_press = self.extents + self._rotation_on_press = self._rotation + self._set_aspect_ratio_correction() return False @@ -3050,13 +3049,8 @@ def _onmove(self, event): dy = event.ydata - eventpress.ydata refmax = None if 'data_coordinates' in state: - aspect_ratio = 1 refx, refy = dx, dy else: - figure_size = self.ax.get_figure().get_size_inches() - ll, ur = self.ax.get_position() * figure_size - width, height = ur - ll - aspect_ratio = height / width * self.ax.get_data_ratio() refx = event.xdata / (eventpress.xdata + 1e-6) refy = event.ydata / (eventpress.ydata + 1e-6) @@ -3067,8 +3061,9 @@ def _onmove(self, event): a = np.array([eventpress.xdata, eventpress.ydata]) b = np.array(self.center) c = np.array([event.xdata, event.ydata]) - self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) - - np.arctan2(a[1]-b[1], a[0]-b[0])) + angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) - + np.arctan2(a[1]-b[1], a[0]-b[0])) + self.rotation = np.rad2deg(self._rotation_on_press + angle) # resize an existing shape elif self._active_handle and self._active_handle != 'C': @@ -3083,10 +3078,10 @@ def _onmove(self, event): refmax = max(refx, refy, key=abs) if self._active_handle in ['E', 'W'] or refmax == refx: hw = event.xdata - center[0] - hh = hw / aspect_ratio + hh = hw / self._aspect_ratio_correction else: hh = event.ydata - center[1] - hw = hh * aspect_ratio + hw = hh * self._aspect_ratio_correction else: hw = size_on_press[0] / 2 hh = size_on_press[1] / 2 @@ -3119,10 +3114,12 @@ def _onmove(self, event): refmax = max(refx, refy, key=abs) if self._active_handle in ['E', 'W'] or refmax == refx: sign = np.sign(event.ydata - y0) - y1 = y0 + sign * abs(x1 - x0) / aspect_ratio + y1 = y0 + sign * abs(x1 - x0) / \ + self._aspect_ratio_correction else: sign = np.sign(event.xdata - x0) - x1 = x0 + sign * abs(y1 - y0) * aspect_ratio + x1 = x0 + sign * abs(y1 - y0) * \ + self._aspect_ratio_correction # move existing shape elif self._active_handle == 'C': @@ -3149,9 +3146,9 @@ def _onmove(self, event): if 'square' in state: refmax = max(refx, refy, key=abs) if refmax == refx: - dy = dx / aspect_ratio + dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction else: - dx = dy * aspect_ratio + dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction # from center if 'center' in state: @@ -3168,6 +3165,18 @@ def _onmove(self, event): self.extents = x0, x1, y0, y1 + def _on_key_press(self, event): + key = event.key or '' + key = key.replace('ctrl', 'control') + for (state, modifier) in self._state_modifier_keys.items(): + if modifier in key.split('+'): + if state in ['rotate', 'data_coordinates']: + if state in self._default_state: + self._default_state.discard(state) + else: + self._default_state.add(state) + self._set_aspect_ratio_correction() + @property def _rect_bbox(self): if self._drawtype == 'box': @@ -3178,8 +3187,27 @@ def _rect_bbox(self): y0, y1 = min(y), max(y) return x0, y0, x1 - x0, y1 - y0 + def _set_aspect_ratio_correction(self): + aspect_ratio = self.ax._get_aspect_ratio() + if not hasattr(self._selection_artist, '_aspect_ratio_correction'): + # Aspect ratio correction is not supported with deprecated + # drawtype='line'. Remove this block in matplotlib 3.7 + self._aspect_ratio_correction = 1 + return + + self._selection_artist._aspect_ratio_correction = aspect_ratio + if 'data_coordinates' in self._state | self._default_state: + self._aspect_ratio_correction = 1 + else: + self._aspect_ratio_correction = aspect_ratio + def _get_rotation_transform(self): - return Affine2D().rotate_around(*self.center, self._rotation) + aspect_ratio = self.ax._get_aspect_ratio() + return Affine2D().translate(-self.center[0], -self.center[1]) \ + .scale(1, aspect_ratio) \ + .rotate(self._rotation) \ + .scale(1, 1 / aspect_ratio) \ + .translate(*self.center) @property def corners(self): @@ -3234,14 +3262,17 @@ def extents(self, extents): @property def rotation(self): - """Rotation in degree.""" + """Rotation in degree in interval [0, 45].""" return np.rad2deg(self._rotation) @rotation.setter def rotation(self, value): - self._rotation = np.deg2rad(value) - # call extents setter to draw shape and update handles positions - self.extents = self.extents + # Restrict to a limited range of rotation [0, 45] to avoid changing + # order of handles + if 0 <= value and value <= 45: + self._rotation = np.deg2rad(value) + # call extents setter to draw shape and update handles positions + self.extents = self.extents draw_shape = _api.deprecate_privatize_attribute('3.5') @@ -3352,7 +3383,7 @@ def _draw_shape(self, extents): self._selection_artist.center = center self._selection_artist.width = 2 * a self._selection_artist.height = 2 * b - self._selection_artist.set_angle(self.rotation) + self._selection_artist.angle = self.rotation else: rad = np.deg2rad(np.arange(31) * 12) x = a * np.cos(rad) + center[0] From 7c4712823bac2885aed24679efda29fa82319312 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Wed, 18 Aug 2021 15:45:47 +0100 Subject: [PATCH 07/14] Add what's new entry and fix linting --- doc/users/next_whats_new/selector_improvement.rst | 8 ++++++++ lib/matplotlib/widgets.py | 1 + 2 files changed, 9 insertions(+) create mode 100644 doc/users/next_whats_new/selector_improvement.rst diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst new file mode 100644 index 000000000000..11a730eb50d9 --- /dev/null +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -0,0 +1,8 @@ +Selectors improvement +--------------------- + +The `~matplotlib.widgets.RectangleSelector` and +`~matplotlib.widgets.EllipseSelector` can now be rotated interactively. The rotation is +enabled and disabled by pressing the 'r' key. +The "square" shape can be defined in data or display coordinate system as defined +by the *data_coordinates* modifier. diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6ccb7b692e36..ce41caee61ce 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2103,6 +2103,7 @@ def add_default_state(self, value): f'following: {keys}.') self._default_state.add(value) + class SpanSelector(_SelectorWidget): """ Visually select a min/max range on a single axis and call a function with From 8546ca4a87b929c266acd55aa3699cf6efd421ce Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Tue, 12 Oct 2021 16:52:22 +0100 Subject: [PATCH 08/14] Improve documentation, docstring and comments --- doc/api/next_api_changes/deprecations/20839-EP.rst | 4 ++-- lib/matplotlib/axes/_axes.py | 5 ++++- lib/matplotlib/patches.py | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/20839-EP.rst b/doc/api/next_api_changes/deprecations/20839-EP.rst index 403edc2ef628..db7e7dcb69a7 100644 --- a/doc/api/next_api_changes/deprecations/20839-EP.rst +++ b/doc/api/next_api_changes/deprecations/20839-EP.rst @@ -1,4 +1,4 @@ Selector widget state internals ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -*state_modifier_keys* have to be defined when creating the selector widget. The -*state_modifier_keys* attribute is deprecated. +*state_modifier_keys* now have to be defined when creating a selector widget. +The *state_modifier_keys* attribute is deprecated. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index bf9b2ce23aef..53ab692d63d5 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -8096,7 +8096,10 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5, triplot = mtri.triplot def _get_aspect_ratio(self): - """Convenience method to calculate aspect ratio of the axes.""" + """ + Convenience method to calculate the aspect ratio of the axes in + the display coordinate system. + """ figure_size = self.get_figure().get_size_inches() ll, ur = self.get_position() * figure_size width, height = ur - ll diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index c90f3dff2531..19c131fe1fe1 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -738,6 +738,10 @@ def __init__(self, xy, width, height, angle=0.0, self.angle = float(angle) self.rotate_around_center = rotate_around_center # Required for RectangleSelector with axes aspect ratio != 1 + # The patch is defined in data coordinates and when changing the + # selector with square modifier and not in data coordinates, we need + # to correct for the aspect ratio difference between the data and + # display coordinate systems. self._aspect_ratio_correction = 1.0 self._convert_units() # Validate the inputs. From 5b9acfe738071cd70f44875151e710554d4a98e2 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 16 Oct 2021 19:29:19 +0100 Subject: [PATCH 09/14] Make rectangle patch rotation point more generic --- lib/matplotlib/patches.py | 28 +++++++++++++++++----------- lib/matplotlib/widgets.py | 2 +- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 19c131fe1fe1..eee0c85ddef1 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -706,8 +706,8 @@ def __str__(self): return fmt % pars @docstring.dedent_interpd - def __init__(self, xy, width, height, angle=0.0, - rotate_around_center=False, **kwargs): + def __init__(self, xy, width, height, angle=0.0, *, + rotation_point='xy', **kwargs): """ Parameters ---------- @@ -718,12 +718,11 @@ def __init__(self, xy, width, height, angle=0.0, height : float Rectangle height. angle : float, default: 0 - Rotation in degrees anti-clockwise about *xy* if - *rotate_around_center* if False, otherwise rotate around the - center of the rectangle - rotate_around_center : bool, default: False - If True, the rotation is performed around the center of the - rectangle. + Rotation in degrees anti-clockwise about the rotation point. + rotation_point : {'xy', 'center', (number, number)}, default: 'xy' + If ``'xy'``, rotate around the anchor point. If ``'center'`` rotate + around the center. If 2-tuple of number, rotate around these + coordinate. Other Parameters ---------------- @@ -736,7 +735,7 @@ def __init__(self, xy, width, height, angle=0.0, self._width = width self._height = height self.angle = float(angle) - self.rotate_around_center = rotate_around_center + self.rotation_point = rotation_point # Required for RectangleSelector with axes aspect ratio != 1 # The patch is defined in data coordinates and when changing the # selector with square modifier and not in data coordinates, we need @@ -763,11 +762,14 @@ def get_patch_transform(self): # important to call the accessor method and not directly access the # transformation member variable. bbox = self.get_bbox() - if self.rotate_around_center: + if self.rotation_point == 'center': width, height = bbox.x1 - bbox.x0, bbox.y1 - bbox.y0 rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2. - else: + elif self.rotation_point == 'xy': rotation_point = bbox.x0, bbox.y0 + elif (isinstance(self.rotation_point[0], Number) and + isinstance(self.rotation_point[1], Number)): + rotation_point = self.rotation_point return transforms.BboxTransformTo(bbox) \ + transforms.Affine2D() \ .translate(-rotation_point[0], -rotation_point[1]) \ @@ -1534,6 +1536,10 @@ def __init__(self, xy, width, height, angle=0, **kwargs): self._angle = angle self._path = Path.unit_circle() # Required for EllipseSelector with axes aspect ratio != 1 + # The patch is defined in data coordinates and when changing the + # selector with square modifier and not in data coordinates, we need + # to correct for the aspect ratio difference between the data and + # display coordinate systems. self._aspect_ratio_correction = 1.0 # Note: This cannot be calculated until this is added to an Axes self._patch_transform = transforms.IdentityTransform() diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index ce41caee61ce..ba5a7090d371 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2948,7 +2948,7 @@ def _handles_artists(self): def _init_shape(self, **props): return Rectangle((0, 0), 0, 1, visible=False, - rotate_around_center=True, **props) + rotation_point='center', **props) def _press(self, event): """Button press event handler.""" From 6e55fc836a13b7eafbb460f4966af53adcbea7e6 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 19 Nov 2021 19:42:08 +0000 Subject: [PATCH 10/14] Improve documentation --- .../deprecations/20839-EP.rst | 4 ++-- .../next_whats_new/selector_improvement.rst | 20 +++++++++++++------ lib/matplotlib/patches.py | 5 +++-- lib/matplotlib/tests/test_widgets.py | 1 - lib/matplotlib/widgets.py | 3 +++ 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/doc/api/next_api_changes/deprecations/20839-EP.rst b/doc/api/next_api_changes/deprecations/20839-EP.rst index db7e7dcb69a7..fa6ff7d3e0f3 100644 --- a/doc/api/next_api_changes/deprecations/20839-EP.rst +++ b/doc/api/next_api_changes/deprecations/20839-EP.rst @@ -1,4 +1,4 @@ Selector widget state internals ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -*state_modifier_keys* now have to be defined when creating a selector widget. -The *state_modifier_keys* attribute is deprecated. +*state_modifier_keys* are immutable now. They can be set when creating the +widget, but cannot be changed afterwards anymore. diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index 11a730eb50d9..ee3d2a252909 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -1,8 +1,16 @@ -Selectors improvement ---------------------- +Rotating selectors and aspect ratio correction +---------------------------------------------- The `~matplotlib.widgets.RectangleSelector` and -`~matplotlib.widgets.EllipseSelector` can now be rotated interactively. The rotation is -enabled and disabled by pressing the 'r' key. -The "square" shape can be defined in data or display coordinate system as defined -by the *data_coordinates* modifier. +`~matplotlib.widgets.EllipseSelector` can now be rotated interactively. + +The rotation is enabled when *rotate* is added to +:py:attr:`~matplotlib.widgets._SelectorWidget.state`, which can be done using +:py:meth:`~matplotlib.widgets._SelectorWidget.add_state` or by striking +the *state_modifier_keys* for *rotate* (default *r*). + +The aspect ratio of the axes can now be taken into account when using the +"square" state. When *data_coordinates* is added to +:py:attr:`~matplotlib.widgets._SelectorWidget.state`, which can be done using +:py:meth:`~matplotlib.widgets._SelectorWidget.add_state` or by striking +the *state_modifier_keys* for *data_coordinates* (default *d*). diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index eee0c85ddef1..5b6b7db25c03 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -721,7 +721,7 @@ def __init__(self, xy, width, height, angle=0.0, *, Rotation in degrees anti-clockwise about the rotation point. rotation_point : {'xy', 'center', (number, number)}, default: 'xy' If ``'xy'``, rotate around the anchor point. If ``'center'`` rotate - around the center. If 2-tuple of number, rotate around these + around the center. If 2-tuple of number, rotate around this coordinate. Other Parameters @@ -740,7 +740,8 @@ def __init__(self, xy, width, height, angle=0.0, *, # The patch is defined in data coordinates and when changing the # selector with square modifier and not in data coordinates, we need # to correct for the aspect ratio difference between the data and - # display coordinate systems. + # display coordinate systems. Its value is typically provide by + # Axes._get_aspect_ratio() self._aspect_ratio_correction = 1.0 self._convert_units() # Validate the inputs. diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 08e118403ef1..585c9df52a5f 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -440,7 +440,6 @@ def onselect(epress, erelease): tool.rotation = 45 assert tool.rotation == 45 # Corners should move - # The third corner is at (100, 145) assert_allclose(tool.corners, np.array([[118.53, 139.75, 111.46, 90.25], [95.25, 116.46, 144.75, 123.54]]), atol=0.01) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index ba5a7090d371..4b38f7a0027f 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -3048,10 +3048,13 @@ def _onmove(self, event): dx = event.xdata - eventpress.xdata dy = event.ydata - eventpress.ydata + # refmax is used when moving the corner handle with the square state + # and is the maximum between refx and refy refmax = None if 'data_coordinates' in state: refx, refy = dx, dy else: + # Add 1e-6 to avoid divided by zero error refx = event.xdata / (eventpress.xdata + 1e-6) refy = event.ydata / (eventpress.ydata + 1e-6) From 55585859d32597a0eaed8eeb462f9c64f64e56b1 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Fri, 19 Nov 2021 21:47:06 +0000 Subject: [PATCH 11/14] Simplify state selectors and validate `rotation_point` attribute Rectangle patch --- .../next_whats_new/selector_improvement.rst | 46 +++++++--- lib/matplotlib/patches.py | 18 +++- lib/matplotlib/tests/test_widgets.py | 78 ++++++++++------ lib/matplotlib/widgets.py | 89 +++++++++++-------- 4 files changed, 152 insertions(+), 79 deletions(-) diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index ee3d2a252909..d15e46ac1104 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -1,16 +1,40 @@ -Rotating selectors and aspect ratio correction ----------------------------------------------- +Selectors improvement: rotation, aspect ratio correction and add/remove state +----------------------------------------------------------------------------- The `~matplotlib.widgets.RectangleSelector` and `~matplotlib.widgets.EllipseSelector` can now be rotated interactively. - -The rotation is enabled when *rotate* is added to -:py:attr:`~matplotlib.widgets._SelectorWidget.state`, which can be done using -:py:meth:`~matplotlib.widgets._SelectorWidget.add_state` or by striking -the *state_modifier_keys* for *rotate* (default *r*). +The rotation is enabled or disable by striking the *r* key +(default value of 'rotate' in *state_modifier_keys*) or by calling +*selector.add_state('rotate')*. The aspect ratio of the axes can now be taken into account when using the -"square" state. When *data_coordinates* is added to -:py:attr:`~matplotlib.widgets._SelectorWidget.state`, which can be done using -:py:meth:`~matplotlib.widgets._SelectorWidget.add_state` or by striking -the *state_modifier_keys* for *data_coordinates* (default *d*). +"square" state. This can be enable or disable by striking the *d* key +(default value of 'data_coordinates' in *state_modifier_keys*) +or by calling *selector.add_state('rotate')*. + +In addition to changing selector state interactively using the modifier keys +defined in *state_modifier_keys*, the selector state can now be changed +programmatically using the *add_state* and *remove_state* method. + + +.. code-block:: python + + import matplotlib.pyplot as plt + from matplotlib.widgets import RectangleSelector + import numpy as np + + values = np.arange(0, 100) + + fig = plt.figure() + ax = fig.add_subplot() + ax.plot(values, values) + + selector = RectangleSelector(ax, print, interactive=True, drag_from_anywhere=True) + selector.add_state('rotate') # alternatively press 'r' key + # rotate the selector interactively + + selector.remove_state('rotate') # alternatively press 'r' key + + selector.add_state('square') + # to keep the aspect ratio in data coordinates + selector.add_state('data_coordinates') diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 5b6b7db25c03..b6c6d536638d 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -768,8 +768,7 @@ def get_patch_transform(self): rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2. elif self.rotation_point == 'xy': rotation_point = bbox.x0, bbox.y0 - elif (isinstance(self.rotation_point[0], Number) and - isinstance(self.rotation_point[1], Number)): + else: rotation_point = self.rotation_point return transforms.BboxTransformTo(bbox) \ + transforms.Affine2D() \ @@ -779,6 +778,21 @@ def get_patch_transform(self): .scale(1, 1 / self._aspect_ratio_correction) \ .translate(*rotation_point) + @property + def rotation_point(self): + return self._rotation_point + + @rotation_point.setter + def rotation_point(self, value): + if value in ['center', 'xy'] or ( + isinstance(value, tuple) and len(value) == 2 and + isinstance(value[0], Number) and isinstance(value[1], Number) + ): + self._rotation_point = value + else: + raise ValueError("`rotation_point` must be one of " + "{'xy', 'center', (number, number)}.") + def get_x(self): """Return the left coordinate of the rectangle.""" return self._x0 diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 585c9df52a5f..588b0ae229db 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -172,7 +172,7 @@ def onselect(epress, erelease): assert tool.extents == (xdata_new, extents[1], ydata_new, extents[3]) -def test_rectangle_add_default_state(): +def test_rectangle_add_state(): ax = get_ax() def onselect(epress, erelease): @@ -183,18 +183,18 @@ def onselect(epress, erelease): _resize_rectangle(tool, 70, 65, 125, 130) with pytest.raises(ValueError): - tool.add_default_state('unsupported_state') + tool.add_state('unsupported_state') with pytest.raises(ValueError): - tool.add_default_state('clear') - tool.add_default_state('move') - tool.add_default_state('square') - tool.add_default_state('center') - tool.add_default_state('data_coordinates') + tool.add_state('clear') + tool.add_state('move') + tool.add_state('square') + tool.add_state('center') + tool.add_state('data_coordinates') -@pytest.mark.parametrize('use_default_state', [True, False]) -def test_rectangle_resize_center(use_default_state): +@pytest.mark.parametrize('add_state', [True, False]) +def test_rectangle_resize_center(add_state): ax = get_ax() def onselect(epress, erelease): @@ -205,8 +205,8 @@ def onselect(epress, erelease): _resize_rectangle(tool, 70, 65, 125, 130) assert tool.extents == (70.0, 125.0, 65.0, 130.0) - if use_default_state: - tool.add_default_state('center') + if add_state: + tool.add_state('center') use_key = None else: use_key = 'control' @@ -266,8 +266,8 @@ def onselect(epress, erelease): ydata_new, extents[3] - ydiff) -@pytest.mark.parametrize('use_default_state', [True, False]) -def test_rectangle_resize_square(use_default_state): +@pytest.mark.parametrize('add_state', [True, False]) +def test_rectangle_resize_square(add_state): ax = get_ax() def onselect(epress, erelease): @@ -278,8 +278,8 @@ def onselect(epress, erelease): _resize_rectangle(tool, 70, 65, 120, 115) assert tool.extents == (70.0, 120.0, 65.0, 115.0) - if use_default_state: - tool.add_default_state('square') + if add_state: + tool.add_state('square') use_key = None else: use_key = 'shift' @@ -348,8 +348,8 @@ def onselect(epress, erelease): tool = widgets.RectangleSelector(ax, onselect, interactive=True) # Create rectangle _resize_rectangle(tool, 70, 65, 120, 115) - tool.add_default_state('square') - tool.add_default_state('center') + tool.add_state('square') + tool.add_state('center') assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0)) # resize NE handle @@ -421,18 +421,16 @@ def onselect(epress, erelease): do_event(tool, 'onmove', xdata=130, ydata=140) do_event(tool, 'release', xdata=130, ydata=140) assert tool.extents == (100, 130, 100, 140) - assert len(tool._default_state) == 0 assert len(tool._state) == 0 # Rotate anticlockwise using top-right corner do_event(tool, 'on_key_press', key='r') - assert tool._default_state == set(['rotate']) - assert len(tool._state) == 0 + assert tool._state == set(['rotate']) + assert len(tool._state) == 1 do_event(tool, 'press', xdata=130, ydata=140) do_event(tool, 'onmove', xdata=120, ydata=145) do_event(tool, 'release', xdata=120, ydata=145) do_event(tool, 'on_key_press', key='r') - assert len(tool._default_state) == 0 assert len(tool._state) == 0 # Extents shouldn't change (as shape of rectangle hasn't changed) assert tool.extents == (100, 130, 100, 140) @@ -450,6 +448,30 @@ def onselect(epress, erelease): do_event(tool, 'release', xdata=110, ydata=160) assert_allclose(tool.extents, (100, 139.75, 100, 151.82), atol=0.01) + if selector_class == widgets.RectangleSelector: + with pytest.raises(ValueError): + tool._selection_artist.rotation_point = 'unvalid_value' + + +def test_rectange_add_remove_set(): + ax = get_ax() + + def onselect(epress, erelease): + pass + + tool = widgets.RectangleSelector(ax, onselect=onselect, interactive=True) + # Draw rectangle + do_event(tool, 'press', xdata=100, ydata=100) + do_event(tool, 'onmove', xdata=130, ydata=140) + do_event(tool, 'release', xdata=130, ydata=140) + assert tool.extents == (100, 130, 100, 140) + assert len(tool._state) == 0 + for state in ['rotate', 'data_coordinates', 'square', 'center']: + tool.add_state(state) + assert len(tool._state) == 1 + tool.remove_state(state) + assert len(tool._state) == 0 + def test_rectangle_resize_square_center_aspect(): ax = get_ax() @@ -461,8 +483,8 @@ def onselect(epress, erelease): tool = widgets.RectangleSelector(ax, onselect, interactive=True) # Create rectangle _resize_rectangle(tool, 70, 65, 120, 115) - tool.add_default_state('square') - tool.add_default_state('center') + tool.add_state('square') + tool.add_state('center') assert tool.extents == (70.0, 120.0, 65.0, 115.0) # resize E handle @@ -905,7 +927,7 @@ def onselect(*args): assert tool.extents == (10, 50) -def test_span_selector_add_default_state(): +def test_span_selector_add_state(): ax = get_ax() def onselect(*args): @@ -914,13 +936,13 @@ def onselect(*args): tool = widgets.SpanSelector(ax, onselect, 'horizontal', interactive=True) with pytest.raises(ValueError): - tool.add_default_state('unsupported_state') + tool.add_state('unsupported_state') with pytest.raises(ValueError): - tool.add_default_state('center') + tool.add_state('center') with pytest.raises(ValueError): - tool.add_default_state('square') + tool.add_state('square') - tool.add_default_state('move') + tool.add_state('move') def test_tool_line_handle(): diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 4b38f7a0027f..4786b0b262ff 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1831,7 +1831,6 @@ def __init__(self, ax, onselect, useblit=False, button=None, self._eventrelease = None self._prev_event = None self._state = set() - self._default_state = set() eventpress = _api.deprecate_privatize_attribute("3.5") eventrelease = _api.deprecate_privatize_attribute("3.5") @@ -1999,10 +1998,18 @@ def on_key_press(self, event): self.clear() return for (state, modifier) in self._state_modifier_keys.items(): - # 'rotate' and 'data_coordinates' are added in _default_state - if (modifier in key.split('+') and - state not in ['rotate', 'data_coordinates']): - self._state.add(state) + if modifier in key.split('+'): + # 'rotate' and 'data_coordinates' are changing _state on + # press and are not removed from _state when releasing + if state in ['rotate', 'data_coordinates']: + if state in self._state: + self._state.discard(state) + else: + self._state.add(state) + else: + self._state.add(state) + if 'data_coordinates' in state: + self._set_aspect_ratio_correction() self._on_key_press(event) def _on_key_press(self, event): @@ -2014,7 +2021,10 @@ def on_key_release(self, event): key = event.key or '' key = key.replace('ctrl', 'control') for (state, modifier) in self._state_modifier_keys.items(): - if modifier in key.split('+'): + # 'rotate' and 'data_coordinates' are changing _state on + # press and are not removed from _state when releasing + if (modifier in key.split('+') and + state not in ['rotate', 'data_coordinates']): self._state.discard(state) self._on_key_release(event) @@ -2068,17 +2078,39 @@ def set_handle_props(self, **handle_props): self.update() self._handle_props.update(handle_props) - @property - def default_state(self): + def _validate_state(self, value): + supported_state = [ + key for key, value in self._state_modifier_keys.items() + if key != 'clear' and value != 'not-applicable' + ] + if value not in supported_state: + keys = ', '.join(supported_state) + raise ValueError('Setting default state must be one of the ' + f'following: {keys}.') + + def add_state(self, value): """ - Default state of the selector, which affect the widget's behavior. See - the `state_modifier_keys` parameters for details. + Add a state to define the widget's behavior. See the + `state_modifier_keys` parameters for details. + + Parameters + ---------- + value : str + Must be a supported state of the selector. See the + `state_modifier_keys` parameters for details. + + Raises + ------ + ValueError + When the value is not supported by the selector. + """ - return tuple(self._default_state) + self._validate_state(value) + self._state.add(value) - def add_default_state(self, value): + def remove_state(self, value): """ - Add a default state to define the widget's behavior. See the + Remove a state to define the widget's behavior. See the `state_modifier_keys` parameters for details. Parameters @@ -2093,15 +2125,8 @@ def add_default_state(self, value): When the value is not supported by the selector. """ - supported_default_state = [ - key for key, value in self._state_modifier_keys.items() - if key != 'clear' and value != 'not-applicable' - ] - if value not in supported_default_state: - keys = ', '.join(supported_default_state) - raise ValueError('Setting default state must be one of the ' - f'following: {keys}.') - self._default_state.add(value) + self._validate_state(value) + self._state.remove(value) class SpanSelector(_SelectorWidget): @@ -2501,7 +2526,7 @@ def _set_active_handle(self, event): # Prioritise center handle over other handles # Use 'C' to match the notation used in the RectangleSelector - if 'move' in self._state | self._default_state: + if 'move' in self._state: self._active_handle = 'C' elif e_dist > self.grab_range: # Not close to any handles @@ -3032,7 +3057,7 @@ def _release(self, event): def _onmove(self, event): """Motion notify event handler.""" - state = self._state | self._default_state + state = self._state rotate = ('rotate' in state and self._active_handle in self._corner_order) eventpress = self._eventpress @@ -3169,18 +3194,6 @@ def _onmove(self, event): self.extents = x0, x1, y0, y1 - def _on_key_press(self, event): - key = event.key or '' - key = key.replace('ctrl', 'control') - for (state, modifier) in self._state_modifier_keys.items(): - if modifier in key.split('+'): - if state in ['rotate', 'data_coordinates']: - if state in self._default_state: - self._default_state.discard(state) - else: - self._default_state.add(state) - self._set_aspect_ratio_correction() - @property def _rect_bbox(self): if self._drawtype == 'box': @@ -3200,7 +3213,7 @@ def _set_aspect_ratio_correction(self): return self._selection_artist._aspect_ratio_correction = aspect_ratio - if 'data_coordinates' in self._state | self._default_state: + if 'data_coordinates' in self._state: self._aspect_ratio_correction = 1 else: self._aspect_ratio_correction = aspect_ratio @@ -3309,7 +3322,7 @@ def _set_active_handle(self, event): e_idx, e_dist = self._edge_handles.closest(event.x, event.y) m_idx, m_dist = self._center_handle.closest(event.x, event.y) - if 'move' in self._state | self._default_state: + if 'move' in self._state: self._active_handle = 'C' # Set active handle as closest handle, if mouse click is close enough. elif m_dist < self.grab_range * 2: From 8ce27d0ccdf7cf266197c743fe04790879a69958 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 20 Nov 2021 10:15:30 +0000 Subject: [PATCH 12/14] Replace the `data_coordinates` state by the `use_data_coordinates` argument --- .../next_whats_new/selector_improvement.rst | 11 ++-- lib/matplotlib/tests/test_widgets.py | 52 +++++++++---------- lib/matplotlib/widgets.py | 42 +++++++-------- 3 files changed, 51 insertions(+), 54 deletions(-) diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index d15e46ac1104..9264e0a1defb 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -8,9 +8,8 @@ The rotation is enabled or disable by striking the *r* key *selector.add_state('rotate')*. The aspect ratio of the axes can now be taken into account when using the -"square" state. This can be enable or disable by striking the *d* key -(default value of 'data_coordinates' in *state_modifier_keys*) -or by calling *selector.add_state('rotate')*. +"square" state. This is enable by specifying *use_data_coordinates='True'* when +the selector is initialized. In addition to changing selector state interactively using the modifier keys defined in *state_modifier_keys*, the selector state can now be changed @@ -29,12 +28,12 @@ programmatically using the *add_state* and *remove_state* method. ax = fig.add_subplot() ax.plot(values, values) - selector = RectangleSelector(ax, print, interactive=True, drag_from_anywhere=True) + selector = RectangleSelector(ax, print, interactive=True, + drag_from_anywhere=True, + use_data_coordinates=True) selector.add_state('rotate') # alternatively press 'r' key # rotate the selector interactively selector.remove_state('rotate') # alternatively press 'r' key selector.add_state('square') - # to keep the aspect ratio in data coordinates - selector.add_state('data_coordinates') diff --git a/lib/matplotlib/tests/test_widgets.py b/lib/matplotlib/tests/test_widgets.py index 588b0ae229db..8ab3d71040dc 100644 --- a/lib/matplotlib/tests/test_widgets.py +++ b/lib/matplotlib/tests/test_widgets.py @@ -190,7 +190,6 @@ def onselect(epress, erelease): tool.add_state('move') tool.add_state('square') tool.add_state('center') - tool.add_state('data_coordinates') @pytest.mark.parametrize('add_state', [True, False]) @@ -466,48 +465,49 @@ def onselect(epress, erelease): do_event(tool, 'release', xdata=130, ydata=140) assert tool.extents == (100, 130, 100, 140) assert len(tool._state) == 0 - for state in ['rotate', 'data_coordinates', 'square', 'center']: + for state in ['rotate', 'square', 'center']: tool.add_state(state) assert len(tool._state) == 1 tool.remove_state(state) assert len(tool._state) == 0 -def test_rectangle_resize_square_center_aspect(): +@pytest.mark.parametrize('use_data_coordinates', [False, True]) +def test_rectangle_resize_square_center_aspect(use_data_coordinates): ax = get_ax() ax.set_aspect(0.8) def onselect(epress, erelease): pass - tool = widgets.RectangleSelector(ax, onselect, interactive=True) + tool = widgets.RectangleSelector(ax, onselect, interactive=True, + use_data_coordinates=use_data_coordinates) # Create rectangle _resize_rectangle(tool, 70, 65, 120, 115) + assert tool.extents == (70.0, 120.0, 65.0, 115.0) tool.add_state('square') tool.add_state('center') - assert tool.extents == (70.0, 120.0, 65.0, 115.0) - - # resize E handle - extents = tool.extents - xdata, ydata = extents[1], extents[3] - xdiff = 10 - xdata_new, ydata_new = xdata + xdiff, ydata - ychange = xdiff * 1 / tool._aspect_ratio_correction - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, - 46.25, 133.75]) - # use data coordinates - do_event(tool, 'on_key_press', key='d') - # resize E handle - extents = tool.extents - xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] - xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 - xdata_new, ydata_new = xdata + xdiff, ydata - ychange = width / 2 + xdiff - _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) - assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, - ycenter - ychange, ycenter + ychange]) + if use_data_coordinates: + # resize E handle + extents = tool.extents + xdata, ydata, width = extents[1], extents[3], extents[1] - extents[0] + xdiff, ycenter = 10, extents[2] + (extents[3] - extents[2]) / 2 + xdata_new, ydata_new = xdata + xdiff, ydata + ychange = width / 2 + xdiff + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + ycenter - ychange, ycenter + ychange]) + else: + # resize E handle + extents = tool.extents + xdata, ydata = extents[1], extents[3] + xdiff = 10 + xdata_new, ydata_new = xdata + xdiff, ydata + ychange = xdiff * 1 / tool._aspect_ratio_correction + _resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new) + assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new, + 46.25, 133.75]) def test_ellipse(): diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 4786b0b262ff..6ac937545deb 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1801,7 +1801,7 @@ def _update(self): class _SelectorWidget(AxesWidget): def __init__(self, ax, onselect, useblit=False, button=None, - state_modifier_keys=None): + state_modifier_keys=None, use_data_coordinates=False): super().__init__(ax) self.visible = True @@ -1811,9 +1811,9 @@ def __init__(self, ax, onselect, useblit=False, button=None, self._state_modifier_keys = dict(move=' ', clear='escape', square='shift', center='control', - data_coordinates='d', rotate='r') self._state_modifier_keys.update(state_modifier_keys or {}) + self._use_data_coordinates = use_data_coordinates self.background = None @@ -1999,17 +1999,15 @@ def on_key_press(self, event): return for (state, modifier) in self._state_modifier_keys.items(): if modifier in key.split('+'): - # 'rotate' and 'data_coordinates' are changing _state on - # press and are not removed from _state when releasing - if state in ['rotate', 'data_coordinates']: + # 'rotate' is changing _state on press and is not removed + # from _state when releasing + if state == 'rotate': if state in self._state: self._state.discard(state) else: self._state.add(state) else: self._state.add(state) - if 'data_coordinates' in state: - self._set_aspect_ratio_correction() self._on_key_press(event) def _on_key_press(self, event): @@ -2019,12 +2017,10 @@ def on_key_release(self, event): """Key release event handler and validator.""" if self.active: key = event.key or '' - key = key.replace('ctrl', 'control') for (state, modifier) in self._state_modifier_keys.items(): - # 'rotate' and 'data_coordinates' are changing _state on - # press and are not removed from _state when releasing - if (modifier in key.split('+') and - state not in ['rotate', 'data_coordinates']): + # 'rotate' is changing _state on press and is not removed + # from _state when releasing + if modifier in key.split('+') and state != 'rotate': self._state.discard(state) self._on_key_release(event) @@ -2236,7 +2232,6 @@ def __init__(self, ax, onselect, direction, minspan=0, useblit=False, state_modifier_keys = dict(clear='escape', square='not-applicable', center='not-applicable', - data_coordinates='not-applicable', rotate='not-applicable') super().__init__(ax, onselect, useblit=useblit, button=button, state_modifier_keys=state_modifier_keys) @@ -2811,13 +2806,11 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) - "clear": Clear the current shape, default: "escape". - "square": Make the shape square, default: "shift". - "center": change the shape around its center, default: "ctrl". - - "data_coordinates": define if data or figure coordinates should be - used to define the square shape, default: "d" - "rotate": Rotate the shape around its center, default: "r". "square" and "center" can be combined. The square shape can be defined - in data or figure coordinates as determined by the ``data_coordinates`` - modifier, which can be enable and disable by pressing the 'd' key. + in data or figure coordinates as determined by the + ``use_data_coordinates`` argument specified when creating the selector. drag_from_anywhere : bool, default: False If `True`, the widget can be moved by clicking anywhere within @@ -2827,6 +2820,10 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) If `True`, the event triggered outside the span selector will be ignored. + use_data_coordinates : bool, default: False + If `True`, the "square" shape of the selector is defined in + data coordinates instead of figure coordinates. + """ @@ -2871,9 +2868,11 @@ def __init__(self, ax, onselect, drawtype='box', lineprops=None, props=None, spancoords='data', button=None, grab_range=10, handle_props=None, interactive=False, state_modifier_keys=None, - drag_from_anywhere=False, ignore_event_outside=False): + drag_from_anywhere=False, ignore_event_outside=False, + use_data_coordinates=False): super().__init__(ax, onselect, useblit=useblit, button=button, - state_modifier_keys=state_modifier_keys) + state_modifier_keys=state_modifier_keys, + use_data_coordinates=use_data_coordinates) self.visible = True self._interactive = interactive @@ -3076,7 +3075,7 @@ def _onmove(self, event): # refmax is used when moving the corner handle with the square state # and is the maximum between refx and refy refmax = None - if 'data_coordinates' in state: + if self._use_data_coordinates: refx, refy = dx, dy else: # Add 1e-6 to avoid divided by zero error @@ -3213,7 +3212,7 @@ def _set_aspect_ratio_correction(self): return self._selection_artist._aspect_ratio_correction = aspect_ratio - if 'data_coordinates' in self._state: + if self._use_data_coordinates: self._aspect_ratio_correction = 1 else: self._aspect_ratio_correction = aspect_ratio @@ -3581,7 +3580,6 @@ def __init__(self, ax, onselect, useblit=False, move_all='shift', move='not-applicable', square='not-applicable', center='not-applicable', - data_coordinates='not-applicable', rotate='not-applicable') super().__init__(ax, onselect, useblit=useblit, state_modifier_keys=state_modifier_keys) From c394be82bfc1205adef2fa6359ddc5e932486fb8 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Sat, 4 Dec 2021 18:58:55 +0000 Subject: [PATCH 13/14] Improve documentation --- .../next_whats_new/selector_improvement.rst | 8 ++--- lib/matplotlib/patches.py | 1 + lib/matplotlib/widgets.py | 31 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index 9264e0a1defb..94cf90937456 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -4,16 +4,16 @@ Selectors improvement: rotation, aspect ratio correction and add/remove state The `~matplotlib.widgets.RectangleSelector` and `~matplotlib.widgets.EllipseSelector` can now be rotated interactively. The rotation is enabled or disable by striking the *r* key -(default value of 'rotate' in *state_modifier_keys*) or by calling -*selector.add_state('rotate')*. +('r' is the default key mapped to 'rotate' in *state_modifier_keys*) or by calling +``selector.add_state('rotate')``. The aspect ratio of the axes can now be taken into account when using the -"square" state. This is enable by specifying *use_data_coordinates='True'* when +"square" state. This is enabled by specifying ``use_data_coordinates='True'`` when the selector is initialized. In addition to changing selector state interactively using the modifier keys defined in *state_modifier_keys*, the selector state can now be changed -programmatically using the *add_state* and *remove_state* method. +programmatically using the *add_state* and *remove_state* methods. .. code-block:: python diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index b6c6d536638d..b83714bdcd7b 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -780,6 +780,7 @@ def get_patch_transform(self): @property def rotation_point(self): + """The rotation point of the patch.""" return self._rotation_point @rotation_point.setter diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 6ac937545deb..8ac1572ea5a2 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -2074,37 +2074,34 @@ def set_handle_props(self, **handle_props): self.update() self._handle_props.update(handle_props) - def _validate_state(self, value): + def _validate_state(self, state): supported_state = [ key for key, value in self._state_modifier_keys.items() if key != 'clear' and value != 'not-applicable' ] - if value not in supported_state: - keys = ', '.join(supported_state) - raise ValueError('Setting default state must be one of the ' - f'following: {keys}.') + _api.check_in_list(supported_state, state=state) - def add_state(self, value): + def add_state(self, state): """ Add a state to define the widget's behavior. See the `state_modifier_keys` parameters for details. Parameters ---------- - value : str + state : str Must be a supported state of the selector. See the `state_modifier_keys` parameters for details. Raises ------ ValueError - When the value is not supported by the selector. + When the state is not supported by the selector. """ - self._validate_state(value) - self._state.add(value) + self._validate_state(state) + self._state.add(state) - def remove_state(self, value): + def remove_state(self, state): """ Remove a state to define the widget's behavior. See the `state_modifier_keys` parameters for details. @@ -2118,11 +2115,11 @@ def remove_state(self, value): Raises ------ ValueError - When the value is not supported by the selector. + When the state is not supported by the selector. """ - self._validate_state(value) - self._state.remove(value) + self._validate_state(state) + self._state.remove(state) class SpanSelector(_SelectorWidget): @@ -2193,7 +2190,7 @@ def on_select(min: float, max: float) -> Any state_modifier_keys : dict, optional Keyboard modifiers which affect the widget's behavior. Values - amend the defaults. + amend the defaults, which are: - "clear": Clear the current shape, default: "escape". @@ -2800,7 +2797,7 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) state_modifier_keys : dict, optional Keyboard modifiers which affect the widget's behavior. Values - amend the defaults. + amend the defaults, which are: - "move": Move the existing shape, default: no modifier. - "clear": Clear the current shape, default: "escape". @@ -2855,6 +2852,8 @@ class RectangleSelector(_SelectorWidget): props=props) >>> fig.show() + >>> selector.add_state('square') + See also: :doc:`/gallery/widgets/rectangle_selector` """ From 56710f5b5988e067b1f3730f74995199b0980673 Mon Sep 17 00:00:00 2001 From: Eric Prestat Date: Mon, 6 Dec 2021 18:43:54 +0000 Subject: [PATCH 14/14] Improve documentation and syntax --- .../deprecations/20839-EP.rst | 4 ++-- .../rectangle_patch_rotation.rst | 5 +++++ .../next_whats_new/selector_improvement.rst | 5 +++-- lib/matplotlib/widgets.py | 22 ++++++++++++------- 4 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 doc/users/next_whats_new/rectangle_patch_rotation.rst diff --git a/doc/api/next_api_changes/deprecations/20839-EP.rst b/doc/api/next_api_changes/deprecations/20839-EP.rst index fa6ff7d3e0f3..1388abca194f 100644 --- a/doc/api/next_api_changes/deprecations/20839-EP.rst +++ b/doc/api/next_api_changes/deprecations/20839-EP.rst @@ -1,4 +1,4 @@ Selector widget state internals ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -*state_modifier_keys* are immutable now. They can be set when creating the -widget, but cannot be changed afterwards anymore. +The *state_modifier_keys* attribute have been privatized and the modifier keys +needs to be set when creating the widget. diff --git a/doc/users/next_whats_new/rectangle_patch_rotation.rst b/doc/users/next_whats_new/rectangle_patch_rotation.rst new file mode 100644 index 000000000000..ba3c1b336604 --- /dev/null +++ b/doc/users/next_whats_new/rectangle_patch_rotation.rst @@ -0,0 +1,5 @@ +Rectangle patch rotation point +------------------------------ + +The rotation point of the `~matplotlib.patches.Rectangle` can now be set to 'xy', +'center' or a 2-tuple of numbers. diff --git a/doc/users/next_whats_new/selector_improvement.rst b/doc/users/next_whats_new/selector_improvement.rst index 94cf90937456..f19282335705 100644 --- a/doc/users/next_whats_new/selector_improvement.rst +++ b/doc/users/next_whats_new/selector_improvement.rst @@ -2,8 +2,9 @@ Selectors improvement: rotation, aspect ratio correction and add/remove state ----------------------------------------------------------------------------- The `~matplotlib.widgets.RectangleSelector` and -`~matplotlib.widgets.EllipseSelector` can now be rotated interactively. -The rotation is enabled or disable by striking the *r* key +`~matplotlib.widgets.EllipseSelector` can now be rotated interactively between +-45° and 45°. The range limits are currently dictated by the implementation. +The rotation is enabled or disabled by striking the *r* key ('r' is the default key mapped to 'rotate' in *state_modifier_keys*) or by calling ``selector.add_state('rotate')``. diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 8ac1572ea5a2..826d990db776 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -1835,7 +1835,7 @@ def __init__(self, ax, onselect, useblit=False, button=None, eventpress = _api.deprecate_privatize_attribute("3.5") eventrelease = _api.deprecate_privatize_attribute("3.5") state = _api.deprecate_privatize_attribute("3.5") - state_modifier_keys = _api.deprecate_privatize_attribute("3.5") + state_modifier_keys = _api.deprecate_privatize_attribute("3.6") def set_active(self, active): super().set_active(active) @@ -2803,7 +2803,8 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) - "clear": Clear the current shape, default: "escape". - "square": Make the shape square, default: "shift". - "center": change the shape around its center, default: "ctrl". - - "rotate": Rotate the shape around its center, default: "r". + - "rotate": Rotate the shape around its center between -45° and 45°, + default: "r". "square" and "center" can be combined. The square shape can be defined in data or figure coordinates as determined by the @@ -3061,7 +3062,8 @@ def _onmove(self, event): eventpress = self._eventpress # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move - if not (self._active_handle == 'C' or rotate): + move = self._active_handle == 'C' + if not move and not rotate: inv_tr = self._get_rotation_transform().inverted() event.xdata, event.ydata = inv_tr.transform( [event.xdata, event.ydata]) @@ -3078,8 +3080,8 @@ def _onmove(self, event): refx, refy = dx, dy else: # Add 1e-6 to avoid divided by zero error - refx = event.xdata / (eventpress.xdata + 1e-6) - refy = event.ydata / (eventpress.ydata + 1e-6) + refx = event.xdata / (eventpress.xdata or 1E-6) + refy = event.ydata / (eventpress.ydata or 1E-6) x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape @@ -3099,6 +3101,7 @@ def _onmove(self, event): # Keeping the center fixed if 'center' in state: + # hh, hw are half-height and half-width if 'square' in state: # when using a corner, find which reference to use if self._active_handle in self._corner_order: @@ -3277,14 +3280,17 @@ def extents(self, extents): @property def rotation(self): - """Rotation in degree in interval [0, 45].""" + """ + Rotation in degree in interval [-45°, 45°]. The rotation is limited in + range to keep the implementation simple. + """ return np.rad2deg(self._rotation) @rotation.setter def rotation(self, value): - # Restrict to a limited range of rotation [0, 45] to avoid changing + # Restrict to a limited range of rotation [-45°, 45°] to avoid changing # order of handles - if 0 <= value and value <= 45: + if -45 <= value and value <= 45: self._rotation = np.deg2rad(value) # call extents setter to draw shape and update handles positions self.extents = self.extents