Coverage for /usr/lib/python3/dist-packages/matplotlib/widgets.py: 17%
1811 statements
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2025-06-14 15:25 +0200
1"""
2GUI neutral widgets
3===================
5Widgets that are designed to work for any of the GUI backends.
6All of these widgets require you to predefine a `matplotlib.axes.Axes`
7instance and pass that as the first parameter. Matplotlib doesn't try to
8be too smart with respect to layout -- you will have to figure out how
9wide and tall you want your Axes to be to accommodate your widget.
10"""
12from contextlib import ExitStack
13import copy
14from numbers import Integral, Number
16import numpy as np
18import matplotlib as mpl
19from . import (_api, _docstring, backend_tools, cbook, colors, ticker,
20 transforms)
21from .lines import Line2D
22from .patches import Circle, Rectangle, Ellipse, Polygon
23from .transforms import TransformedPatchPath, Affine2D
26class LockDraw:
27 """
28 Some widgets, like the cursor, draw onto the canvas, and this is not
29 desirable under all circumstances, like when the toolbar is in zoom-to-rect
30 mode and drawing a rectangle. To avoid this, a widget can acquire a
31 canvas' lock with ``canvas.widgetlock(widget)`` before drawing on the
32 canvas; this will prevent other widgets from doing so at the same time (if
33 they also try to acquire the lock first).
34 """
36 def __init__(self):
37 self._owner = None
39 def __call__(self, o):
40 """Reserve the lock for *o*."""
41 if not self.available(o):
42 raise ValueError('already locked')
43 self._owner = o
45 def release(self, o):
46 """Release the lock from *o*."""
47 if not self.available(o):
48 raise ValueError('you do not own this lock')
49 self._owner = None
51 def available(self, o):
52 """Return whether drawing is available to *o*."""
53 return not self.locked() or self.isowner(o)
55 def isowner(self, o):
56 """Return whether *o* owns this lock."""
57 return self._owner is o
59 def locked(self):
60 """Return whether the lock is currently held by an owner."""
61 return self._owner is not None
64class Widget:
65 """
66 Abstract base class for GUI neutral widgets.
67 """
68 drawon = True
69 eventson = True
70 _active = True
72 def set_active(self, active):
73 """Set whether the widget is active."""
74 self._active = active
76 def get_active(self):
77 """Get whether the widget is active."""
78 return self._active
80 # set_active is overridden by SelectorWidgets.
81 active = property(get_active, set_active, doc="Is the widget active?")
83 def ignore(self, event):
84 """
85 Return whether *event* should be ignored.
87 This method should be called at the beginning of any event callback.
88 """
89 return not self.active
92class AxesWidget(Widget):
93 """
94 Widget connected to a single `~matplotlib.axes.Axes`.
96 To guarantee that the widget remains responsive and not garbage-collected,
97 a reference to the object should be maintained by the user.
99 This is necessary because the callback registry
100 maintains only weak-refs to the functions, which are member
101 functions of the widget. If there are no references to the widget
102 object it may be garbage collected which will disconnect the callbacks.
104 Attributes
105 ----------
106 ax : `~matplotlib.axes.Axes`
107 The parent Axes for the widget.
108 canvas : `~matplotlib.backend_bases.FigureCanvasBase`
109 The parent figure canvas for the widget.
110 active : bool
111 If False, the widget does not respond to events.
112 """
114 def __init__(self, ax):
115 self.ax = ax
116 self.canvas = ax.figure.canvas
117 self._cids = []
119 def connect_event(self, event, callback):
120 """
121 Connect a callback function with an event.
123 This should be used in lieu of ``figure.canvas.mpl_connect`` since this
124 function stores callback ids for later clean up.
125 """
126 cid = self.canvas.mpl_connect(event, callback)
127 self._cids.append(cid)
129 def disconnect_events(self):
130 """Disconnect all events created by this widget."""
131 for c in self._cids:
132 self.canvas.mpl_disconnect(c)
135class Button(AxesWidget):
136 """
137 A GUI neutral button.
139 For the button to remain responsive you must keep a reference to it.
140 Call `.on_clicked` to connect to the button.
142 Attributes
143 ----------
144 ax
145 The `matplotlib.axes.Axes` the button renders into.
146 label
147 A `matplotlib.text.Text` instance.
148 color
149 The color of the button when not hovering.
150 hovercolor
151 The color of the button when hovering.
152 """
154 def __init__(self, ax, label, image=None,
155 color='0.85', hovercolor='0.95'):
156 """
157 Parameters
158 ----------
159 ax : `~matplotlib.axes.Axes`
160 The `~.axes.Axes` instance the button will be placed into.
161 label : str
162 The button text.
163 image : array-like or PIL Image
164 The image to place in the button, if not *None*. The parameter is
165 directly forwarded to `~matplotlib.axes.Axes.imshow`.
166 color : color
167 The color of the button when not activated.
168 hovercolor : color
169 The color of the button when the mouse is over it.
170 """
171 super().__init__(ax)
173 if image is not None:
174 ax.imshow(image)
175 self.label = ax.text(0.5, 0.5, label,
176 verticalalignment='center',
177 horizontalalignment='center',
178 transform=ax.transAxes)
180 self._observers = cbook.CallbackRegistry(signals=["clicked"])
182 self.connect_event('button_press_event', self._click)
183 self.connect_event('button_release_event', self._release)
184 self.connect_event('motion_notify_event', self._motion)
185 ax.set_navigate(False)
186 ax.set_facecolor(color)
187 ax.set_xticks([])
188 ax.set_yticks([])
189 self.color = color
190 self.hovercolor = hovercolor
192 def _click(self, event):
193 if self.ignore(event) or event.inaxes != self.ax or not self.eventson:
194 return
195 if event.canvas.mouse_grabber != self.ax:
196 event.canvas.grab_mouse(self.ax)
198 def _release(self, event):
199 if self.ignore(event) or event.canvas.mouse_grabber != self.ax:
200 return
201 event.canvas.release_mouse(self.ax)
202 if self.eventson and event.inaxes == self.ax:
203 self._observers.process('clicked', event)
205 def _motion(self, event):
206 if self.ignore(event):
207 return
208 c = self.hovercolor if event.inaxes == self.ax else self.color
209 if not colors.same_color(c, self.ax.get_facecolor()):
210 self.ax.set_facecolor(c)
211 if self.drawon:
212 self.ax.figure.canvas.draw()
214 def on_clicked(self, func):
215 """
216 Connect the callback function *func* to button click events.
218 Returns a connection id, which can be used to disconnect the callback.
219 """
220 return self._observers.connect('clicked', lambda event: func(event))
222 def disconnect(self, cid):
223 """Remove the callback function with connection id *cid*."""
224 self._observers.disconnect(cid)
227class SliderBase(AxesWidget):
228 """
229 The base class for constructing Slider widgets. Not intended for direct
230 usage.
232 For the slider to remain responsive you must maintain a reference to it.
233 """
234 def __init__(self, ax, orientation, closedmin, closedmax,
235 valmin, valmax, valfmt, dragging, valstep):
236 if ax.name == '3d':
237 raise ValueError('Sliders cannot be added to 3D Axes')
239 super().__init__(ax)
240 _api.check_in_list(['horizontal', 'vertical'], orientation=orientation)
242 self.orientation = orientation
243 self.closedmin = closedmin
244 self.closedmax = closedmax
245 self.valmin = valmin
246 self.valmax = valmax
247 self.valstep = valstep
248 self.drag_active = False
249 self.valfmt = valfmt
251 if orientation == "vertical":
252 ax.set_ylim((valmin, valmax))
253 axis = ax.yaxis
254 else:
255 ax.set_xlim((valmin, valmax))
256 axis = ax.xaxis
258 self._fmt = axis.get_major_formatter()
259 if not isinstance(self._fmt, ticker.ScalarFormatter):
260 self._fmt = ticker.ScalarFormatter()
261 self._fmt.set_axis(axis)
262 self._fmt.set_useOffset(False) # No additive offset.
263 self._fmt.set_useMathText(True) # x sign before multiplicative offset.
265 ax.set_axis_off()
266 ax.set_navigate(False)
268 self.connect_event("button_press_event", self._update)
269 self.connect_event("button_release_event", self._update)
270 if dragging:
271 self.connect_event("motion_notify_event", self._update)
272 self._observers = cbook.CallbackRegistry(signals=["changed"])
274 def _stepped_value(self, val):
275 """Return *val* coerced to closest number in the ``valstep`` grid."""
276 if isinstance(self.valstep, Number):
277 val = (self.valmin
278 + round((val - self.valmin) / self.valstep) * self.valstep)
279 elif self.valstep is not None:
280 valstep = np.asanyarray(self.valstep)
281 if valstep.ndim != 1:
282 raise ValueError(
283 f"valstep must have 1 dimension but has {valstep.ndim}"
284 )
285 val = valstep[np.argmin(np.abs(valstep - val))]
286 return val
288 def disconnect(self, cid):
289 """
290 Remove the observer with connection id *cid*.
292 Parameters
293 ----------
294 cid : int
295 Connection id of the observer to be removed.
296 """
297 self._observers.disconnect(cid)
299 def reset(self):
300 """Reset the slider to the initial value."""
301 if np.any(self.val != self.valinit):
302 self.set_val(self.valinit)
305class Slider(SliderBase):
306 """
307 A slider representing a floating point range.
309 Create a slider from *valmin* to *valmax* in Axes *ax*. For the slider to
310 remain responsive you must maintain a reference to it. Call
311 :meth:`on_changed` to connect to the slider event.
313 Attributes
314 ----------
315 val : float
316 Slider value.
317 """
319 def __init__(self, ax, label, valmin, valmax, valinit=0.5, valfmt=None,
320 closedmin=True, closedmax=True, slidermin=None,
321 slidermax=None, dragging=True, valstep=None,
322 orientation='horizontal', *, initcolor='r',
323 track_color='lightgrey', handle_style=None, **kwargs):
324 """
325 Parameters
326 ----------
327 ax : Axes
328 The Axes to put the slider in.
330 label : str
331 Slider label.
333 valmin : float
334 The minimum value of the slider.
336 valmax : float
337 The maximum value of the slider.
339 valinit : float, default: 0.5
340 The slider initial position.
342 valfmt : str, default: None
343 %-format string used to format the slider value. If None, a
344 `.ScalarFormatter` is used instead.
346 closedmin : bool, default: True
347 Whether the slider interval is closed on the bottom.
349 closedmax : bool, default: True
350 Whether the slider interval is closed on the top.
352 slidermin : Slider, default: None
353 Do not allow the current slider to have a value less than
354 the value of the Slider *slidermin*.
356 slidermax : Slider, default: None
357 Do not allow the current slider to have a value greater than
358 the value of the Slider *slidermax*.
360 dragging : bool, default: True
361 If True the slider can be dragged by the mouse.
363 valstep : float or array-like, default: None
364 If a float, the slider will snap to multiples of *valstep*.
365 If an array the slider will snap to the values in the array.
367 orientation : {'horizontal', 'vertical'}, default: 'horizontal'
368 The orientation of the slider.
370 initcolor : color, default: 'r'
371 The color of the line at the *valinit* position. Set to ``'none'``
372 for no line.
374 track_color : color, default: 'lightgrey'
375 The color of the background track. The track is accessible for
376 further styling via the *track* attribute.
378 handle_style : dict
379 Properties of the slider handle. Default values are
381 ========= ===== ======= ========================================
382 Key Value Default Description
383 ========= ===== ======= ========================================
384 facecolor color 'white' The facecolor of the slider handle.
385 edgecolor color '.75' The edgecolor of the slider handle.
386 size int 10 The size of the slider handle in points.
387 ========= ===== ======= ========================================
389 Other values will be transformed as marker{foo} and passed to the
390 `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will
391 result in ``markerstyle = 'x'``.
393 Notes
394 -----
395 Additional kwargs are passed on to ``self.poly`` which is the
396 `~matplotlib.patches.Polygon` that draws the slider knob. See the
397 `.Polygon` documentation for valid property names (``facecolor``,
398 ``edgecolor``, ``alpha``, etc.).
399 """
400 super().__init__(ax, orientation, closedmin, closedmax,
401 valmin, valmax, valfmt, dragging, valstep)
403 if slidermin is not None and not hasattr(slidermin, 'val'):
404 raise ValueError(
405 f"Argument slidermin ({type(slidermin)}) has no 'val'")
406 if slidermax is not None and not hasattr(slidermax, 'val'):
407 raise ValueError(
408 f"Argument slidermax ({type(slidermax)}) has no 'val'")
409 self.slidermin = slidermin
410 self.slidermax = slidermax
411 valinit = self._value_in_bounds(valinit)
412 if valinit is None:
413 valinit = valmin
414 self.val = valinit
415 self.valinit = valinit
417 defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10}
418 handle_style = {} if handle_style is None else handle_style
419 marker_props = {
420 f'marker{k}': v for k, v in {**defaults, **handle_style}.items()
421 }
423 if orientation == 'vertical':
424 self.track = Rectangle(
425 (.25, 0), .5, 1,
426 transform=ax.transAxes,
427 facecolor=track_color
428 )
429 ax.add_patch(self.track)
430 self.poly = ax.axhspan(valmin, valinit, .25, .75, **kwargs)
431 # Drawing a longer line and clipping it to the track avoids
432 # pixelation-related asymmetries.
433 self.hline = ax.axhline(valinit, 0, 1, color=initcolor, lw=1,
434 clip_path=TransformedPatchPath(self.track))
435 handleXY = [[0.5], [valinit]]
436 else:
437 self.track = Rectangle(
438 (0, .25), 1, .5,
439 transform=ax.transAxes,
440 facecolor=track_color
441 )
442 ax.add_patch(self.track)
443 self.poly = ax.axvspan(valmin, valinit, .25, .75, **kwargs)
444 self.vline = ax.axvline(valinit, 0, 1, color=initcolor, lw=1,
445 clip_path=TransformedPatchPath(self.track))
446 handleXY = [[valinit], [0.5]]
447 self._handle, = ax.plot(
448 *handleXY,
449 "o",
450 **marker_props,
451 clip_on=False
452 )
454 if orientation == 'vertical':
455 self.label = ax.text(0.5, 1.02, label, transform=ax.transAxes,
456 verticalalignment='bottom',
457 horizontalalignment='center')
459 self.valtext = ax.text(0.5, -0.02, self._format(valinit),
460 transform=ax.transAxes,
461 verticalalignment='top',
462 horizontalalignment='center')
463 else:
464 self.label = ax.text(-0.02, 0.5, label, transform=ax.transAxes,
465 verticalalignment='center',
466 horizontalalignment='right')
468 self.valtext = ax.text(1.02, 0.5, self._format(valinit),
469 transform=ax.transAxes,
470 verticalalignment='center',
471 horizontalalignment='left')
473 self.set_val(valinit)
475 def _value_in_bounds(self, val):
476 """Makes sure *val* is with given bounds."""
477 val = self._stepped_value(val)
479 if val <= self.valmin:
480 if not self.closedmin:
481 return
482 val = self.valmin
483 elif val >= self.valmax:
484 if not self.closedmax:
485 return
486 val = self.valmax
488 if self.slidermin is not None and val <= self.slidermin.val:
489 if not self.closedmin:
490 return
491 val = self.slidermin.val
493 if self.slidermax is not None and val >= self.slidermax.val:
494 if not self.closedmax:
495 return
496 val = self.slidermax.val
497 return val
499 def _update(self, event):
500 """Update the slider position."""
501 if self.ignore(event) or event.button != 1:
502 return
504 if event.name == 'button_press_event' and event.inaxes == self.ax:
505 self.drag_active = True
506 event.canvas.grab_mouse(self.ax)
508 if not self.drag_active:
509 return
511 elif ((event.name == 'button_release_event') or
512 (event.name == 'button_press_event' and
513 event.inaxes != self.ax)):
514 self.drag_active = False
515 event.canvas.release_mouse(self.ax)
516 return
517 if self.orientation == 'vertical':
518 val = self._value_in_bounds(event.ydata)
519 else:
520 val = self._value_in_bounds(event.xdata)
521 if val not in [None, self.val]:
522 self.set_val(val)
524 def _format(self, val):
525 """Pretty-print *val*."""
526 if self.valfmt is not None:
527 return self.valfmt % val
528 else:
529 _, s, _ = self._fmt.format_ticks([self.valmin, val, self.valmax])
530 # fmt.get_offset is actually the multiplicative factor, if any.
531 return s + self._fmt.get_offset()
533 def set_val(self, val):
534 """
535 Set slider value to *val*.
537 Parameters
538 ----------
539 val : float
540 """
541 xy = self.poly.xy
542 if self.orientation == 'vertical':
543 xy[1] = .25, val
544 xy[2] = .75, val
545 self._handle.set_ydata([val])
546 else:
547 xy[2] = val, .75
548 xy[3] = val, .25
549 self._handle.set_xdata([val])
550 self.poly.xy = xy
551 self.valtext.set_text(self._format(val))
552 if self.drawon:
553 self.ax.figure.canvas.draw_idle()
554 self.val = val
555 if self.eventson:
556 self._observers.process('changed', val)
558 def on_changed(self, func):
559 """
560 Connect *func* as callback function to changes of the slider value.
562 Parameters
563 ----------
564 func : callable
565 Function to call when slider is changed.
566 The function must accept a single float as its arguments.
568 Returns
569 -------
570 int
571 Connection id (which can be used to disconnect *func*).
572 """
573 return self._observers.connect('changed', lambda val: func(val))
576class RangeSlider(SliderBase):
577 """
578 A slider representing a range of floating point values. Defines the min and
579 max of the range via the *val* attribute as a tuple of (min, max).
581 Create a slider that defines a range contained within [*valmin*, *valmax*]
582 in Axes *ax*. For the slider to remain responsive you must maintain a
583 reference to it. Call :meth:`on_changed` to connect to the slider event.
585 Attributes
586 ----------
587 val : tuple of float
588 Slider value.
589 """
591 def __init__(
592 self,
593 ax,
594 label,
595 valmin,
596 valmax,
597 valinit=None,
598 valfmt=None,
599 closedmin=True,
600 closedmax=True,
601 dragging=True,
602 valstep=None,
603 orientation="horizontal",
604 track_color='lightgrey',
605 handle_style=None,
606 **kwargs,
607 ):
608 """
609 Parameters
610 ----------
611 ax : Axes
612 The Axes to put the slider in.
614 label : str
615 Slider label.
617 valmin : float
618 The minimum value of the slider.
620 valmax : float
621 The maximum value of the slider.
623 valinit : tuple of float or None, default: None
624 The initial positions of the slider. If None the initial positions
625 will be at the 25th and 75th percentiles of the range.
627 valfmt : str, default: None
628 %-format string used to format the slider values. If None, a
629 `.ScalarFormatter` is used instead.
631 closedmin : bool, default: True
632 Whether the slider interval is closed on the bottom.
634 closedmax : bool, default: True
635 Whether the slider interval is closed on the top.
637 dragging : bool, default: True
638 If True the slider can be dragged by the mouse.
640 valstep : float, default: None
641 If given, the slider will snap to multiples of *valstep*.
643 orientation : {'horizontal', 'vertical'}, default: 'horizontal'
644 The orientation of the slider.
646 track_color : color, default: 'lightgrey'
647 The color of the background track. The track is accessible for
648 further styling via the *track* attribute.
650 handle_style : dict
651 Properties of the slider handles. Default values are
653 ========= ===== ======= =========================================
654 Key Value Default Description
655 ========= ===== ======= =========================================
656 facecolor color 'white' The facecolor of the slider handles.
657 edgecolor color '.75' The edgecolor of the slider handles.
658 size int 10 The size of the slider handles in points.
659 ========= ===== ======= =========================================
661 Other values will be transformed as marker{foo} and passed to the
662 `~.Line2D` constructor. e.g. ``handle_style = {'style'='x'}`` will
663 result in ``markerstyle = 'x'``.
665 Notes
666 -----
667 Additional kwargs are passed on to ``self.poly`` which is the
668 `~matplotlib.patches.Polygon` that draws the slider knob. See the
669 `.Polygon` documentation for valid property names (``facecolor``,
670 ``edgecolor``, ``alpha``, etc.).
671 """
672 super().__init__(ax, orientation, closedmin, closedmax,
673 valmin, valmax, valfmt, dragging, valstep)
675 # Set a value to allow _value_in_bounds() to work.
676 self.val = [valmin, valmax]
677 if valinit is None:
678 # Place at the 25th and 75th percentiles
679 extent = valmax - valmin
680 valinit = np.array([valmin + extent * 0.25,
681 valmin + extent * 0.75])
682 else:
683 valinit = self._value_in_bounds(valinit)
684 self.val = valinit
685 self.valinit = valinit
687 defaults = {'facecolor': 'white', 'edgecolor': '.75', 'size': 10}
688 handle_style = {} if handle_style is None else handle_style
689 marker_props = {
690 f'marker{k}': v for k, v in {**defaults, **handle_style}.items()
691 }
693 if orientation == "vertical":
694 self.track = Rectangle(
695 (.25, 0), .5, 2,
696 transform=ax.transAxes,
697 facecolor=track_color
698 )
699 ax.add_patch(self.track)
700 poly_transform = self.ax.get_yaxis_transform(which="grid")
701 handleXY_1 = [.5, valinit[0]]
702 handleXY_2 = [.5, valinit[1]]
703 else:
704 self.track = Rectangle(
705 (0, .25), 1, .5,
706 transform=ax.transAxes,
707 facecolor=track_color
708 )
709 ax.add_patch(self.track)
710 poly_transform = self.ax.get_xaxis_transform(which="grid")
711 handleXY_1 = [valinit[0], .5]
712 handleXY_2 = [valinit[1], .5]
713 self.poly = Polygon(np.zeros([5, 2]), **kwargs)
714 self._update_selection_poly(*valinit)
715 self.poly.set_transform(poly_transform)
716 self.poly.get_path()._interpolation_steps = 100
717 self.ax.add_patch(self.poly)
718 self.ax._request_autoscale_view()
719 self._handles = [
720 ax.plot(
721 *handleXY_1,
722 "o",
723 **marker_props,
724 clip_on=False
725 )[0],
726 ax.plot(
727 *handleXY_2,
728 "o",
729 **marker_props,
730 clip_on=False
731 )[0]
732 ]
734 if orientation == "vertical":
735 self.label = ax.text(
736 0.5,
737 1.02,
738 label,
739 transform=ax.transAxes,
740 verticalalignment="bottom",
741 horizontalalignment="center",
742 )
744 self.valtext = ax.text(
745 0.5,
746 -0.02,
747 self._format(valinit),
748 transform=ax.transAxes,
749 verticalalignment="top",
750 horizontalalignment="center",
751 )
752 else:
753 self.label = ax.text(
754 -0.02,
755 0.5,
756 label,
757 transform=ax.transAxes,
758 verticalalignment="center",
759 horizontalalignment="right",
760 )
762 self.valtext = ax.text(
763 1.02,
764 0.5,
765 self._format(valinit),
766 transform=ax.transAxes,
767 verticalalignment="center",
768 horizontalalignment="left",
769 )
771 self._active_handle = None
772 self.set_val(valinit)
774 def _update_selection_poly(self, vmin, vmax):
775 """
776 Update the vertices of the *self.poly* slider in-place
777 to cover the data range *vmin*, *vmax*.
778 """
779 # The vertices are positioned
780 # 1 ------ 2
781 # | |
782 # 0, 4 ---- 3
783 verts = self.poly.xy
784 if self.orientation == "vertical":
785 verts[0] = verts[4] = .25, vmin
786 verts[1] = .25, vmax
787 verts[2] = .75, vmax
788 verts[3] = .75, vmin
789 else:
790 verts[0] = verts[4] = vmin, .25
791 verts[1] = vmin, .75
792 verts[2] = vmax, .75
793 verts[3] = vmax, .25
795 def _min_in_bounds(self, min):
796 """Ensure the new min value is between valmin and self.val[1]."""
797 if min <= self.valmin:
798 if not self.closedmin:
799 return self.val[0]
800 min = self.valmin
802 if min > self.val[1]:
803 min = self.val[1]
804 return self._stepped_value(min)
806 def _max_in_bounds(self, max):
807 """Ensure the new max value is between valmax and self.val[0]."""
808 if max >= self.valmax:
809 if not self.closedmax:
810 return self.val[1]
811 max = self.valmax
813 if max <= self.val[0]:
814 max = self.val[0]
815 return self._stepped_value(max)
817 def _value_in_bounds(self, vals):
818 """Clip min, max values to the bounds."""
819 return (self._min_in_bounds(vals[0]), self._max_in_bounds(vals[1]))
821 def _update_val_from_pos(self, pos):
822 """Update the slider value based on a given position."""
823 idx = np.argmin(np.abs(self.val - pos))
824 if idx == 0:
825 val = self._min_in_bounds(pos)
826 self.set_min(val)
827 else:
828 val = self._max_in_bounds(pos)
829 self.set_max(val)
830 if self._active_handle:
831 if self.orientation == "vertical":
832 self._active_handle.set_ydata([val])
833 else:
834 self._active_handle.set_xdata([val])
836 def _update(self, event):
837 """Update the slider position."""
838 if self.ignore(event) or event.button != 1:
839 return
841 if event.name == "button_press_event" and event.inaxes == self.ax:
842 self.drag_active = True
843 event.canvas.grab_mouse(self.ax)
845 if not self.drag_active:
846 return
848 elif (event.name == "button_release_event") or (
849 event.name == "button_press_event" and event.inaxes != self.ax
850 ):
851 self.drag_active = False
852 event.canvas.release_mouse(self.ax)
853 self._active_handle = None
854 return
856 # determine which handle was grabbed
857 if self.orientation == "vertical":
858 handle_index = np.argmin(
859 np.abs([h.get_ydata()[0] - event.ydata for h in self._handles])
860 )
861 else:
862 handle_index = np.argmin(
863 np.abs([h.get_xdata()[0] - event.xdata for h in self._handles])
864 )
865 handle = self._handles[handle_index]
867 # these checks ensure smooth behavior if the handles swap which one
868 # has a higher value. i.e. if one is dragged over and past the other.
869 if handle is not self._active_handle:
870 self._active_handle = handle
872 if self.orientation == "vertical":
873 self._update_val_from_pos(event.ydata)
874 else:
875 self._update_val_from_pos(event.xdata)
877 def _format(self, val):
878 """Pretty-print *val*."""
879 if self.valfmt is not None:
880 return f"({self.valfmt % val[0]}, {self.valfmt % val[1]})"
881 else:
882 _, s1, s2, _ = self._fmt.format_ticks(
883 [self.valmin, *val, self.valmax]
884 )
885 # fmt.get_offset is actually the multiplicative factor, if any.
886 s1 += self._fmt.get_offset()
887 s2 += self._fmt.get_offset()
888 # Use f string to avoid issues with backslashes when cast to a str
889 return f"({s1}, {s2})"
891 def set_min(self, min):
892 """
893 Set the lower value of the slider to *min*.
895 Parameters
896 ----------
897 min : float
898 """
899 self.set_val((min, self.val[1]))
901 def set_max(self, max):
902 """
903 Set the lower value of the slider to *max*.
905 Parameters
906 ----------
907 max : float
908 """
909 self.set_val((self.val[0], max))
911 def set_val(self, val):
912 """
913 Set slider value to *val*.
915 Parameters
916 ----------
917 val : tuple or array-like of float
918 """
919 val = np.sort(val)
920 _api.check_shape((2,), val=val)
921 vmin, vmax = val
922 vmin = self._min_in_bounds(vmin)
923 vmax = self._max_in_bounds(vmax)
924 self._update_selection_poly(vmin, vmax)
925 if self.orientation == "vertical":
926 self._handles[0].set_ydata([vmin])
927 self._handles[1].set_ydata([vmax])
928 else:
929 self._handles[0].set_xdata([vmin])
930 self._handles[1].set_xdata([vmax])
932 self.valtext.set_text(self._format((vmin, vmax)))
934 if self.drawon:
935 self.ax.figure.canvas.draw_idle()
936 self.val = (vmin, vmax)
937 if self.eventson:
938 self._observers.process("changed", (vmin, vmax))
940 def on_changed(self, func):
941 """
942 Connect *func* as callback function to changes of the slider value.
944 Parameters
945 ----------
946 func : callable
947 Function to call when slider is changed. The function
948 must accept a numpy array with shape (2,) as its argument.
950 Returns
951 -------
952 int
953 Connection id (which can be used to disconnect *func*).
954 """
955 return self._observers.connect('changed', lambda val: func(val))
958class CheckButtons(AxesWidget):
959 r"""
960 A GUI neutral set of check buttons.
962 For the check buttons to remain responsive you must keep a
963 reference to this object.
965 Connect to the CheckButtons with the `.on_clicked` method.
967 Attributes
968 ----------
969 ax : `~matplotlib.axes.Axes`
970 The parent Axes for the widget.
971 labels : list of `.Text`
973 rectangles : list of `.Rectangle`
975 lines : list of (`.Line2D`, `.Line2D`) pairs
976 List of lines for the x's in the checkboxes. These lines exist for
977 each box, but have ``set_visible(False)`` when its box is not checked.
978 """
980 def __init__(self, ax, labels, actives=None):
981 """
982 Add check buttons to `matplotlib.axes.Axes` instance *ax*.
984 Parameters
985 ----------
986 ax : `~matplotlib.axes.Axes`
987 The parent Axes for the widget.
989 labels : list of str
990 The labels of the check buttons.
992 actives : list of bool, optional
993 The initial check states of the buttons. The list must have the
994 same length as *labels*. If not given, all buttons are unchecked.
995 """
996 super().__init__(ax)
998 ax.set_xticks([])
999 ax.set_yticks([])
1000 ax.set_navigate(False)
1002 if actives is None:
1003 actives = [False] * len(labels)
1005 if len(labels) > 1:
1006 dy = 1. / (len(labels) + 1)
1007 ys = np.linspace(1 - dy, dy, len(labels))
1008 else:
1009 dy = 0.25
1010 ys = [0.5]
1012 axcolor = ax.get_facecolor()
1014 self.labels = []
1015 self.lines = []
1016 self.rectangles = []
1018 lineparams = {'color': 'k', 'linewidth': 1.25,
1019 'transform': ax.transAxes, 'solid_capstyle': 'butt'}
1020 for y, label, active in zip(ys, labels, actives):
1021 t = ax.text(0.25, y, label, transform=ax.transAxes,
1022 horizontalalignment='left',
1023 verticalalignment='center')
1025 w, h = dy / 2, dy / 2
1026 x, y = 0.05, y - h / 2
1028 p = Rectangle(xy=(x, y), width=w, height=h, edgecolor='black',
1029 facecolor=axcolor, transform=ax.transAxes)
1031 l1 = Line2D([x, x + w], [y + h, y], **lineparams)
1032 l2 = Line2D([x, x + w], [y, y + h], **lineparams)
1034 l1.set_visible(active)
1035 l2.set_visible(active)
1036 self.labels.append(t)
1037 self.rectangles.append(p)
1038 self.lines.append((l1, l2))
1039 ax.add_patch(p)
1040 ax.add_line(l1)
1041 ax.add_line(l2)
1043 self.connect_event('button_press_event', self._clicked)
1045 self._observers = cbook.CallbackRegistry(signals=["clicked"])
1047 def _clicked(self, event):
1048 if self.ignore(event) or event.button != 1 or event.inaxes != self.ax:
1049 return
1050 for i, (p, t) in enumerate(zip(self.rectangles, self.labels)):
1051 if (t.get_window_extent().contains(event.x, event.y) or
1052 p.get_window_extent().contains(event.x, event.y)):
1053 self.set_active(i)
1054 break
1056 def set_active(self, index):
1057 """
1058 Toggle (activate or deactivate) a check button by index.
1060 Callbacks will be triggered if :attr:`eventson` is True.
1062 Parameters
1063 ----------
1064 index : int
1065 Index of the check button to toggle.
1067 Raises
1068 ------
1069 ValueError
1070 If *index* is invalid.
1071 """
1072 if index not in range(len(self.labels)):
1073 raise ValueError(f'Invalid CheckButton index: {index}')
1075 l1, l2 = self.lines[index]
1076 l1.set_visible(not l1.get_visible())
1077 l2.set_visible(not l2.get_visible())
1079 if self.drawon:
1080 self.ax.figure.canvas.draw()
1082 if self.eventson:
1083 self._observers.process('clicked', self.labels[index].get_text())
1085 def get_status(self):
1086 """
1087 Return a tuple of the status (True/False) of all of the check buttons.
1088 """
1089 return [l1.get_visible() for (l1, l2) in self.lines]
1091 def on_clicked(self, func):
1092 """
1093 Connect the callback function *func* to button click events.
1095 Returns a connection id, which can be used to disconnect the callback.
1096 """
1097 return self._observers.connect('clicked', lambda text: func(text))
1099 def disconnect(self, cid):
1100 """Remove the observer with connection id *cid*."""
1101 self._observers.disconnect(cid)
1104class TextBox(AxesWidget):
1105 """
1106 A GUI neutral text input box.
1108 For the text box to remain responsive you must keep a reference to it.
1110 Call `.on_text_change` to be updated whenever the text changes.
1112 Call `.on_submit` to be updated whenever the user hits enter or
1113 leaves the text entry field.
1115 Attributes
1116 ----------
1117 ax : `~matplotlib.axes.Axes`
1118 The parent Axes for the widget.
1119 label : `.Text`
1121 color : color
1122 The color of the text box when not hovering.
1123 hovercolor : color
1124 The color of the text box when hovering.
1125 """
1127 DIST_FROM_LEFT = _api.deprecate_privatize_attribute("3.5")
1129 def __init__(self, ax, label, initial='',
1130 color='.95', hovercolor='1', label_pad=.01,
1131 textalignment="left"):
1132 """
1133 Parameters
1134 ----------
1135 ax : `~matplotlib.axes.Axes`
1136 The `~.axes.Axes` instance the button will be placed into.
1137 label : str
1138 Label for this text box.
1139 initial : str
1140 Initial value in the text box.
1141 color : color
1142 The color of the box.
1143 hovercolor : color
1144 The color of the box when the mouse is over it.
1145 label_pad : float
1146 The distance between the label and the right side of the textbox.
1147 textalignment : {'left', 'center', 'right'}
1148 The horizontal location of the text.
1149 """
1150 super().__init__(ax)
1152 self._DIST_FROM_LEFT = .05
1154 self._text_position = _api.check_getitem(
1155 {"left": 0.05, "center": 0.5, "right": 0.95},
1156 textalignment=textalignment)
1158 self.label = ax.text(
1159 -label_pad, 0.5, label, transform=ax.transAxes,
1160 verticalalignment='center', horizontalalignment='right')
1162 # TextBox's text object should not parse mathtext at all.
1163 self.text_disp = self.ax.text(
1164 self._text_position, 0.5, initial, transform=self.ax.transAxes,
1165 verticalalignment='center', horizontalalignment=textalignment,
1166 parse_math=False)
1168 self._observers = cbook.CallbackRegistry(signals=["change", "submit"])
1170 ax.set(
1171 xlim=(0, 1), ylim=(0, 1), # s.t. cursor appears from first click.
1172 navigate=False, facecolor=color,
1173 xticks=[], yticks=[])
1175 self.cursor_index = 0
1177 self.cursor = ax.vlines(0, 0, 0, visible=False, color="k", lw=1,
1178 transform=mpl.transforms.IdentityTransform())
1180 self.connect_event('button_press_event', self._click)
1181 self.connect_event('button_release_event', self._release)
1182 self.connect_event('motion_notify_event', self._motion)
1183 self.connect_event('key_press_event', self._keypress)
1184 self.connect_event('resize_event', self._resize)
1186 self.color = color
1187 self.hovercolor = hovercolor
1189 self.capturekeystrokes = False
1191 @property
1192 def text(self):
1193 return self.text_disp.get_text()
1195 def _rendercursor(self):
1196 # this is a hack to figure out where the cursor should go.
1197 # we draw the text up to where the cursor should go, measure
1198 # and save its dimensions, draw the real text, then put the cursor
1199 # at the saved dimensions
1201 # This causes a single extra draw if the figure has never been rendered
1202 # yet, which should be fine as we're going to repeatedly re-render the
1203 # figure later anyways.
1204 if self.ax.figure._get_renderer() is None:
1205 self.ax.figure.canvas.draw()
1207 text = self.text_disp.get_text() # Save value before overwriting it.
1208 widthtext = text[:self.cursor_index]
1210 bb_text = self.text_disp.get_window_extent()
1211 self.text_disp.set_text(widthtext or ",")
1212 bb_widthtext = self.text_disp.get_window_extent()
1214 if bb_text.y0 == bb_text.y1: # Restoring the height if no text.
1215 bb_text.y0 -= bb_widthtext.height / 2
1216 bb_text.y1 += bb_widthtext.height / 2
1217 elif not widthtext: # Keep width to 0.
1218 bb_text.x1 = bb_text.x0
1219 else: # Move the cursor using width of bb_widthtext.
1220 bb_text.x1 = bb_text.x0 + bb_widthtext.width
1222 self.cursor.set(
1223 segments=[[(bb_text.x1, bb_text.y0), (bb_text.x1, bb_text.y1)]],
1224 visible=True)
1225 self.text_disp.set_text(text)
1227 self.ax.figure.canvas.draw()
1229 def _release(self, event):
1230 if self.ignore(event):
1231 return
1232 if event.canvas.mouse_grabber != self.ax:
1233 return
1234 event.canvas.release_mouse(self.ax)
1236 def _keypress(self, event):
1237 if self.ignore(event):
1238 return
1239 if self.capturekeystrokes:
1240 key = event.key
1241 text = self.text
1242 if len(key) == 1:
1243 text = (text[:self.cursor_index] + key +
1244 text[self.cursor_index:])
1245 self.cursor_index += 1
1246 elif key == "right":
1247 if self.cursor_index != len(text):
1248 self.cursor_index += 1
1249 elif key == "left":
1250 if self.cursor_index != 0:
1251 self.cursor_index -= 1
1252 elif key == "home":
1253 self.cursor_index = 0
1254 elif key == "end":
1255 self.cursor_index = len(text)
1256 elif key == "backspace":
1257 if self.cursor_index != 0:
1258 text = (text[:self.cursor_index - 1] +
1259 text[self.cursor_index:])
1260 self.cursor_index -= 1
1261 elif key == "delete":
1262 if self.cursor_index != len(self.text):
1263 text = (text[:self.cursor_index] +
1264 text[self.cursor_index + 1:])
1265 self.text_disp.set_text(text)
1266 self._rendercursor()
1267 if self.eventson:
1268 self._observers.process('change', self.text)
1269 if key in ["enter", "return"]:
1270 self._observers.process('submit', self.text)
1272 def set_val(self, val):
1273 newval = str(val)
1274 if self.text == newval:
1275 return
1276 self.text_disp.set_text(newval)
1277 self._rendercursor()
1278 if self.eventson:
1279 self._observers.process('change', self.text)
1280 self._observers.process('submit', self.text)
1282 def begin_typing(self, x):
1283 self.capturekeystrokes = True
1284 # Disable keypress shortcuts, which may otherwise cause the figure to
1285 # be saved, closed, etc., until the user stops typing. The way to
1286 # achieve this depends on whether toolmanager is in use.
1287 stack = ExitStack() # Register cleanup actions when user stops typing.
1288 self._on_stop_typing = stack.close
1289 toolmanager = getattr(
1290 self.ax.figure.canvas.manager, "toolmanager", None)
1291 if toolmanager is not None:
1292 # If using toolmanager, lock keypresses, and plan to release the
1293 # lock when typing stops.
1294 toolmanager.keypresslock(self)
1295 stack.callback(toolmanager.keypresslock.release, self)
1296 else:
1297 # If not using toolmanager, disable all keypress-related rcParams.
1298 # Avoid spurious warnings if keymaps are getting deprecated.
1299 with _api.suppress_matplotlib_deprecation_warning():
1300 stack.enter_context(mpl.rc_context(
1301 {k: [] for k in mpl.rcParams if k.startswith("keymap.")}))
1303 def stop_typing(self):
1304 if self.capturekeystrokes:
1305 self._on_stop_typing()
1306 self._on_stop_typing = None
1307 notifysubmit = True
1308 else:
1309 notifysubmit = False
1310 self.capturekeystrokes = False
1311 self.cursor.set_visible(False)
1312 self.ax.figure.canvas.draw()
1313 if notifysubmit and self.eventson:
1314 # Because process() might throw an error in the user's code, only
1315 # call it once we've already done our cleanup.
1316 self._observers.process('submit', self.text)
1318 def position_cursor(self, x):
1319 # now, we have to figure out where the cursor goes.
1320 # approximate it based on assuming all characters the same length
1321 if len(self.text) == 0:
1322 self.cursor_index = 0
1323 else:
1324 bb = self.text_disp.get_window_extent()
1325 ratio = np.clip((x - bb.x0) / bb.width, 0, 1)
1326 self.cursor_index = int(len(self.text) * ratio)
1327 self._rendercursor()
1329 def _click(self, event):
1330 if self.ignore(event):
1331 return
1332 if event.inaxes != self.ax:
1333 self.stop_typing()
1334 return
1335 if not self.eventson:
1336 return
1337 if event.canvas.mouse_grabber != self.ax:
1338 event.canvas.grab_mouse(self.ax)
1339 if not self.capturekeystrokes:
1340 self.begin_typing(event.x)
1341 self.position_cursor(event.x)
1343 def _resize(self, event):
1344 self.stop_typing()
1346 def _motion(self, event):
1347 if self.ignore(event):
1348 return
1349 c = self.hovercolor if event.inaxes == self.ax else self.color
1350 if not colors.same_color(c, self.ax.get_facecolor()):
1351 self.ax.set_facecolor(c)
1352 if self.drawon:
1353 self.ax.figure.canvas.draw()
1355 def on_text_change(self, func):
1356 """
1357 When the text changes, call this *func* with event.
1359 A connection id is returned which can be used to disconnect.
1360 """
1361 return self._observers.connect('change', lambda text: func(text))
1363 def on_submit(self, func):
1364 """
1365 When the user hits enter or leaves the submission box, call this
1366 *func* with event.
1368 A connection id is returned which can be used to disconnect.
1369 """
1370 return self._observers.connect('submit', lambda text: func(text))
1372 def disconnect(self, cid):
1373 """Remove the observer with connection id *cid*."""
1374 self._observers.disconnect(cid)
1377class RadioButtons(AxesWidget):
1378 """
1379 A GUI neutral radio button.
1381 For the buttons to remain responsive you must keep a reference to this
1382 object.
1384 Connect to the RadioButtons with the `.on_clicked` method.
1386 Attributes
1387 ----------
1388 ax : `~matplotlib.axes.Axes`
1389 The parent Axes for the widget.
1390 activecolor : color
1391 The color of the selected button.
1392 labels : list of `.Text`
1393 The button labels.
1394 circles : list of `~.patches.Circle`
1395 The buttons.
1396 value_selected : str
1397 The label text of the currently selected button.
1398 """
1400 def __init__(self, ax, labels, active=0, activecolor='blue'):
1401 """
1402 Add radio buttons to an `~.axes.Axes`.
1404 Parameters
1405 ----------
1406 ax : `~matplotlib.axes.Axes`
1407 The Axes to add the buttons to.
1408 labels : list of str
1409 The button labels.
1410 active : int
1411 The index of the initially selected button.
1412 activecolor : color
1413 The color of the selected button.
1414 """
1415 super().__init__(ax)
1416 self.activecolor = activecolor
1417 self.value_selected = None
1419 ax.set_xticks([])
1420 ax.set_yticks([])
1421 ax.set_navigate(False)
1422 dy = 1. / (len(labels) + 1)
1423 ys = np.linspace(1 - dy, dy, len(labels))
1424 cnt = 0
1425 axcolor = ax.get_facecolor()
1427 # scale the radius of the circle with the spacing between each one
1428 circle_radius = dy / 2 - 0.01
1429 # default to hard-coded value if the radius becomes too large
1430 circle_radius = min(circle_radius, 0.05)
1432 self.labels = []
1433 self.circles = []
1434 for y, label in zip(ys, labels):
1435 t = ax.text(0.25, y, label, transform=ax.transAxes,
1436 horizontalalignment='left',
1437 verticalalignment='center')
1439 if cnt == active:
1440 self.value_selected = label
1441 facecolor = activecolor
1442 else:
1443 facecolor = axcolor
1445 p = Circle(xy=(0.15, y), radius=circle_radius, edgecolor='black',
1446 facecolor=facecolor, transform=ax.transAxes)
1448 self.labels.append(t)
1449 self.circles.append(p)
1450 ax.add_patch(p)
1451 cnt += 1
1453 self.connect_event('button_press_event', self._clicked)
1455 self._observers = cbook.CallbackRegistry(signals=["clicked"])
1457 def _clicked(self, event):
1458 if self.ignore(event) or event.button != 1 or event.inaxes != self.ax:
1459 return
1460 pclicked = self.ax.transAxes.inverted().transform((event.x, event.y))
1461 distances = {}
1462 for i, (p, t) in enumerate(zip(self.circles, self.labels)):
1463 if (t.get_window_extent().contains(event.x, event.y)
1464 or np.linalg.norm(pclicked - p.center) < p.radius):
1465 distances[i] = np.linalg.norm(pclicked - p.center)
1466 if len(distances) > 0:
1467 closest = min(distances, key=distances.get)
1468 self.set_active(closest)
1470 def set_active(self, index):
1471 """
1472 Select button with number *index*.
1474 Callbacks will be triggered if :attr:`eventson` is True.
1475 """
1476 if index not in range(len(self.labels)):
1477 raise ValueError(f'Invalid RadioButton index: {index}')
1479 self.value_selected = self.labels[index].get_text()
1481 for i, p in enumerate(self.circles):
1482 if i == index:
1483 color = self.activecolor
1484 else:
1485 color = self.ax.get_facecolor()
1486 p.set_facecolor(color)
1488 if self.drawon:
1489 self.ax.figure.canvas.draw()
1491 if self.eventson:
1492 self._observers.process('clicked', self.labels[index].get_text())
1494 def on_clicked(self, func):
1495 """
1496 Connect the callback function *func* to button click events.
1498 Returns a connection id, which can be used to disconnect the callback.
1499 """
1500 return self._observers.connect('clicked', func)
1502 def disconnect(self, cid):
1503 """Remove the observer with connection id *cid*."""
1504 self._observers.disconnect(cid)
1507class SubplotTool(Widget):
1508 """
1509 A tool to adjust the subplot params of a `matplotlib.figure.Figure`.
1510 """
1512 def __init__(self, targetfig, toolfig):
1513 """
1514 Parameters
1515 ----------
1516 targetfig : `.Figure`
1517 The figure instance to adjust.
1518 toolfig : `.Figure`
1519 The figure instance to embed the subplot tool into.
1520 """
1522 self.figure = toolfig
1523 self.targetfig = targetfig
1524 toolfig.subplots_adjust(left=0.2, right=0.9)
1525 toolfig.suptitle("Click on slider to adjust subplot param")
1527 self._sliders = []
1528 names = ["left", "bottom", "right", "top", "wspace", "hspace"]
1529 # The last subplot, removed below, keeps space for the "Reset" button.
1530 for name, ax in zip(names, toolfig.subplots(len(names) + 1)):
1531 ax.set_navigate(False)
1532 slider = Slider(ax, name,
1533 0, 1, getattr(targetfig.subplotpars, name))
1534 slider.on_changed(self._on_slider_changed)
1535 self._sliders.append(slider)
1536 toolfig.axes[-1].remove()
1537 (self.sliderleft, self.sliderbottom, self.sliderright, self.slidertop,
1538 self.sliderwspace, self.sliderhspace) = self._sliders
1539 for slider in [self.sliderleft, self.sliderbottom,
1540 self.sliderwspace, self.sliderhspace]:
1541 slider.closedmax = False
1542 for slider in [self.sliderright, self.slidertop]:
1543 slider.closedmin = False
1545 # constraints
1546 self.sliderleft.slidermax = self.sliderright
1547 self.sliderright.slidermin = self.sliderleft
1548 self.sliderbottom.slidermax = self.slidertop
1549 self.slidertop.slidermin = self.sliderbottom
1551 bax = toolfig.add_axes([0.8, 0.05, 0.15, 0.075])
1552 self.buttonreset = Button(bax, 'Reset')
1553 self.buttonreset.on_clicked(self._on_reset)
1555 def _on_slider_changed(self, _):
1556 self.targetfig.subplots_adjust(
1557 **{slider.label.get_text(): slider.val
1558 for slider in self._sliders})
1559 if self.drawon:
1560 self.targetfig.canvas.draw()
1562 def _on_reset(self, event):
1563 with ExitStack() as stack:
1564 # Temporarily disable drawing on self and self's sliders, and
1565 # disconnect slider events (as the subplotparams can be temporarily
1566 # invalid, depending on the order in which they are restored).
1567 stack.enter_context(cbook._setattr_cm(self, drawon=False))
1568 for slider in self._sliders:
1569 stack.enter_context(
1570 cbook._setattr_cm(slider, drawon=False, eventson=False))
1571 # Reset the slider to the initial position.
1572 for slider in self._sliders:
1573 slider.reset()
1574 if self.drawon:
1575 event.canvas.draw() # Redraw the subplottool canvas.
1576 self._on_slider_changed(None) # Apply changes to the target window.
1579class Cursor(AxesWidget):
1580 """
1581 A crosshair cursor that spans the Axes and moves with mouse cursor.
1583 For the cursor to remain responsive you must keep a reference to it.
1585 Parameters
1586 ----------
1587 ax : `matplotlib.axes.Axes`
1588 The `~.axes.Axes` to attach the cursor to.
1589 horizOn : bool, default: True
1590 Whether to draw the horizontal line.
1591 vertOn : bool, default: True
1592 Whether to draw the vertical line.
1593 useblit : bool, default: False
1594 Use blitting for faster drawing if supported by the backend.
1595 See the tutorial :doc:`/tutorials/advanced/blitting` for details.
1597 Other Parameters
1598 ----------------
1599 **lineprops
1600 `.Line2D` properties that control the appearance of the lines.
1601 See also `~.Axes.axhline`.
1603 Examples
1604 --------
1605 See :doc:`/gallery/widgets/cursor`.
1606 """
1608 def __init__(self, ax, horizOn=True, vertOn=True, useblit=False,
1609 **lineprops):
1610 super().__init__(ax)
1612 self.connect_event('motion_notify_event', self.onmove)
1613 self.connect_event('draw_event', self.clear)
1615 self.visible = True
1616 self.horizOn = horizOn
1617 self.vertOn = vertOn
1618 self.useblit = useblit and self.canvas.supports_blit
1620 if self.useblit:
1621 lineprops['animated'] = True
1622 self.lineh = ax.axhline(ax.get_ybound()[0], visible=False, **lineprops)
1623 self.linev = ax.axvline(ax.get_xbound()[0], visible=False, **lineprops)
1625 self.background = None
1626 self.needclear = False
1628 def clear(self, event):
1629 """Internal event handler to clear the cursor."""
1630 if self.ignore(event):
1631 return
1632 if self.useblit:
1633 self.background = self.canvas.copy_from_bbox(self.ax.bbox)
1634 self.linev.set_visible(False)
1635 self.lineh.set_visible(False)
1637 def onmove(self, event):
1638 """Internal event handler to draw the cursor when the mouse moves."""
1639 if self.ignore(event):
1640 return
1641 if not self.canvas.widgetlock.available(self):
1642 return
1643 if event.inaxes != self.ax:
1644 self.linev.set_visible(False)
1645 self.lineh.set_visible(False)
1647 if self.needclear:
1648 self.canvas.draw()
1649 self.needclear = False
1650 return
1651 self.needclear = True
1652 if not self.visible:
1653 return
1654 self.linev.set_xdata((event.xdata, event.xdata))
1656 self.lineh.set_ydata((event.ydata, event.ydata))
1657 self.linev.set_visible(self.visible and self.vertOn)
1658 self.lineh.set_visible(self.visible and self.horizOn)
1660 self._update()
1662 def _update(self):
1663 if self.useblit:
1664 if self.background is not None:
1665 self.canvas.restore_region(self.background)
1666 self.ax.draw_artist(self.linev)
1667 self.ax.draw_artist(self.lineh)
1668 self.canvas.blit(self.ax.bbox)
1669 else:
1670 self.canvas.draw_idle()
1671 return False
1674class MultiCursor(Widget):
1675 """
1676 Provide a vertical (default) and/or horizontal line cursor shared between
1677 multiple Axes.
1679 For the cursor to remain responsive you must keep a reference to it.
1681 Parameters
1682 ----------
1683 canvas : object
1684 This parameter is entirely unused and only kept for back-compatibility.
1686 axes : list of `matplotlib.axes.Axes`
1687 The `~.axes.Axes` to attach the cursor to.
1689 useblit : bool, default: True
1690 Use blitting for faster drawing if supported by the backend.
1691 See the tutorial :doc:`/tutorials/advanced/blitting`
1692 for details.
1694 horizOn : bool, default: False
1695 Whether to draw the horizontal line.
1697 vertOn : bool, default: True
1698 Whether to draw the vertical line.
1700 Other Parameters
1701 ----------------
1702 **lineprops
1703 `.Line2D` properties that control the appearance of the lines.
1704 See also `~.Axes.axhline`.
1706 Examples
1707 --------
1708 See :doc:`/gallery/widgets/multicursor`.
1709 """
1711 @_api.make_keyword_only("3.6", "useblit")
1712 def __init__(self, canvas, axes, useblit=True, horizOn=False, vertOn=True,
1713 **lineprops):
1714 # canvas is stored only to provide the deprecated .canvas attribute;
1715 # once it goes away the unused argument won't need to be stored at all.
1716 self._canvas = canvas
1718 self.axes = axes
1719 self.horizOn = horizOn
1720 self.vertOn = vertOn
1722 self._canvas_infos = {
1723 ax.figure.canvas: {"cids": [], "background": None} for ax in axes}
1725 xmin, xmax = axes[-1].get_xlim()
1726 ymin, ymax = axes[-1].get_ylim()
1727 xmid = 0.5 * (xmin + xmax)
1728 ymid = 0.5 * (ymin + ymax)
1730 self.visible = True
1731 self.useblit = (
1732 useblit
1733 and all(canvas.supports_blit for canvas in self._canvas_infos))
1734 self.needclear = False
1736 if self.useblit:
1737 lineprops['animated'] = True
1739 if vertOn:
1740 self.vlines = [ax.axvline(xmid, visible=False, **lineprops)
1741 for ax in axes]
1742 else:
1743 self.vlines = []
1745 if horizOn:
1746 self.hlines = [ax.axhline(ymid, visible=False, **lineprops)
1747 for ax in axes]
1748 else:
1749 self.hlines = []
1751 self.connect()
1753 canvas = _api.deprecate_privatize_attribute("3.6")
1754 background = _api.deprecated("3.6")(lambda self: (
1755 self._backgrounds[self.axes[0].figure.canvas] if self.axes else None))
1757 def connect(self):
1758 """Connect events."""
1759 for canvas, info in self._canvas_infos.items():
1760 info["cids"] = [
1761 canvas.mpl_connect('motion_notify_event', self.onmove),
1762 canvas.mpl_connect('draw_event', self.clear),
1763 ]
1765 def disconnect(self):
1766 """Disconnect events."""
1767 for canvas, info in self._canvas_infos.items():
1768 for cid in info["cids"]:
1769 canvas.mpl_disconnect(cid)
1770 info["cids"].clear()
1772 def clear(self, event):
1773 """Clear the cursor."""
1774 if self.ignore(event):
1775 return
1776 if self.useblit:
1777 for canvas, info in self._canvas_infos.items():
1778 info["background"] = canvas.copy_from_bbox(canvas.figure.bbox)
1779 for line in self.vlines + self.hlines:
1780 line.set_visible(False)
1782 def onmove(self, event):
1783 if (self.ignore(event)
1784 or event.inaxes not in self.axes
1785 or not event.canvas.widgetlock.available(self)):
1786 return
1787 self.needclear = True
1788 if not self.visible:
1789 return
1790 if self.vertOn:
1791 for line in self.vlines:
1792 line.set_xdata((event.xdata, event.xdata))
1793 line.set_visible(self.visible)
1794 if self.horizOn:
1795 for line in self.hlines:
1796 line.set_ydata((event.ydata, event.ydata))
1797 line.set_visible(self.visible)
1798 self._update()
1800 def _update(self):
1801 if self.useblit:
1802 for canvas, info in self._canvas_infos.items():
1803 if info["background"]:
1804 canvas.restore_region(info["background"])
1805 if self.vertOn:
1806 for ax, line in zip(self.axes, self.vlines):
1807 ax.draw_artist(line)
1808 if self.horizOn:
1809 for ax, line in zip(self.axes, self.hlines):
1810 ax.draw_artist(line)
1811 for canvas in self._canvas_infos:
1812 canvas.blit()
1813 else:
1814 for canvas in self._canvas_infos:
1815 canvas.draw_idle()
1818class _SelectorWidget(AxesWidget):
1820 def __init__(self, ax, onselect, useblit=False, button=None,
1821 state_modifier_keys=None, use_data_coordinates=False):
1822 super().__init__(ax)
1824 self._visible = True
1825 self.onselect = onselect
1826 self.useblit = useblit and self.canvas.supports_blit
1827 self.connect_default_events()
1829 self._state_modifier_keys = dict(move=' ', clear='escape',
1830 square='shift', center='control',
1831 rotate='r')
1832 self._state_modifier_keys.update(state_modifier_keys or {})
1833 self._use_data_coordinates = use_data_coordinates
1835 self.background = None
1837 if isinstance(button, Integral):
1838 self.validButtons = [button]
1839 else:
1840 self.validButtons = button
1842 # Set to True when a selection is completed, otherwise is False
1843 self._selection_completed = False
1845 # will save the data (position at mouseclick)
1846 self._eventpress = None
1847 # will save the data (pos. at mouserelease)
1848 self._eventrelease = None
1849 self._prev_event = None
1850 self._state = set()
1852 eventpress = _api.deprecate_privatize_attribute("3.5")
1853 eventrelease = _api.deprecate_privatize_attribute("3.5")
1854 state = _api.deprecate_privatize_attribute("3.5")
1855 state_modifier_keys = _api.deprecate_privatize_attribute("3.6")
1857 def set_active(self, active):
1858 super().set_active(active)
1859 if active:
1860 self.update_background(None)
1862 def _get_animated_artists(self):
1863 """
1864 Convenience method to get all animated artists of the figure containing
1865 this widget, excluding those already present in self.artists.
1866 The returned tuple is not sorted by 'z_order': z_order sorting is
1867 valid only when considering all artists and not only a subset of all
1868 artists.
1869 """
1870 return tuple(a for ax_ in self.ax.get_figure().get_axes()
1871 for a in ax_.get_children()
1872 if a.get_animated() and a not in self.artists)
1874 def update_background(self, event):
1875 """Force an update of the background."""
1876 # If you add a call to `ignore` here, you'll want to check edge case:
1877 # `release` can call a draw event even when `ignore` is True.
1878 if not self.useblit:
1879 return
1880 # Make sure that widget artists don't get accidentally included in the
1881 # background, by re-rendering the background if needed (and then
1882 # re-re-rendering the canvas with the visible widget artists).
1883 # We need to remove all artists which will be drawn when updating
1884 # the selector: if we have animated artists in the figure, it is safer
1885 # to redrawn by default, in case they have updated by the callback
1886 # zorder needs to be respected when redrawing
1887 artists = sorted(self.artists + self._get_animated_artists(),
1888 key=lambda a: a.get_zorder())
1889 needs_redraw = any(artist.get_visible() for artist in artists)
1890 with ExitStack() as stack:
1891 if needs_redraw:
1892 for artist in artists:
1893 stack.enter_context(artist._cm_set(visible=False))
1894 self.canvas.draw()
1895 self.background = self.canvas.copy_from_bbox(self.ax.bbox)
1896 if needs_redraw:
1897 for artist in artists:
1898 self.ax.draw_artist(artist)
1900 def connect_default_events(self):
1901 """Connect the major canvas events to methods."""
1902 self.connect_event('motion_notify_event', self.onmove)
1903 self.connect_event('button_press_event', self.press)
1904 self.connect_event('button_release_event', self.release)
1905 self.connect_event('draw_event', self.update_background)
1906 self.connect_event('key_press_event', self.on_key_press)
1907 self.connect_event('key_release_event', self.on_key_release)
1908 self.connect_event('scroll_event', self.on_scroll)
1910 def ignore(self, event):
1911 # docstring inherited
1912 if not self.active or not self.ax.get_visible():
1913 return True
1914 # If canvas was locked
1915 if not self.canvas.widgetlock.available(self):
1916 return True
1917 if not hasattr(event, 'button'):
1918 event.button = None
1919 # Only do rectangle selection if event was triggered
1920 # with a desired button
1921 if (self.validButtons is not None
1922 and event.button not in self.validButtons):
1923 return True
1924 # If no button was pressed yet ignore the event if it was out
1925 # of the Axes
1926 if self._eventpress is None:
1927 return event.inaxes != self.ax
1928 # If a button was pressed, check if the release-button is the same.
1929 if event.button == self._eventpress.button:
1930 return False
1931 # If a button was pressed, check if the release-button is the same.
1932 return (event.inaxes != self.ax or
1933 event.button != self._eventpress.button)
1935 def update(self):
1936 """Draw using blit() or draw_idle(), depending on ``self.useblit``."""
1937 if (not self.ax.get_visible() or
1938 self.ax.figure._get_renderer() is None):
1939 return
1940 if self.useblit:
1941 if self.background is not None:
1942 self.canvas.restore_region(self.background)
1943 else:
1944 self.update_background(None)
1945 # We need to draw all artists, which are not included in the
1946 # background, therefore we also draw self._get_animated_artists()
1947 # and we make sure that we respect z_order
1948 artists = sorted(self.artists + self._get_animated_artists(),
1949 key=lambda a: a.get_zorder())
1950 for artist in artists:
1951 self.ax.draw_artist(artist)
1952 self.canvas.blit(self.ax.bbox)
1953 else:
1954 self.canvas.draw_idle()
1956 def _get_data(self, event):
1957 """Get the xdata and ydata for event, with limits."""
1958 if event.xdata is None:
1959 return None, None
1960 xdata = np.clip(event.xdata, *self.ax.get_xbound())
1961 ydata = np.clip(event.ydata, *self.ax.get_ybound())
1962 return xdata, ydata
1964 def _clean_event(self, event):
1965 """
1966 Preprocess an event:
1968 - Replace *event* by the previous event if *event* has no ``xdata``.
1969 - Clip ``xdata`` and ``ydata`` to the axes limits.
1970 - Update the previous event.
1971 """
1972 if event.xdata is None:
1973 event = self._prev_event
1974 else:
1975 event = copy.copy(event)
1976 event.xdata, event.ydata = self._get_data(event)
1977 self._prev_event = event
1978 return event
1980 def press(self, event):
1981 """Button press handler and validator."""
1982 if not self.ignore(event):
1983 event = self._clean_event(event)
1984 self._eventpress = event
1985 self._prev_event = event
1986 key = event.key or ''
1987 key = key.replace('ctrl', 'control')
1988 # move state is locked in on a button press
1989 if key == self._state_modifier_keys['move']:
1990 self._state.add('move')
1991 self._press(event)
1992 return True
1993 return False
1995 def _press(self, event):
1996 """Button press event handler."""
1998 def release(self, event):
1999 """Button release event handler and validator."""
2000 if not self.ignore(event) and self._eventpress:
2001 event = self._clean_event(event)
2002 self._eventrelease = event
2003 self._release(event)
2004 self._eventpress = None
2005 self._eventrelease = None
2006 self._state.discard('move')
2007 return True
2008 return False
2010 def _release(self, event):
2011 """Button release event handler."""
2013 def onmove(self, event):
2014 """Cursor move event handler and validator."""
2015 if not self.ignore(event) and self._eventpress:
2016 event = self._clean_event(event)
2017 self._onmove(event)
2018 return True
2019 return False
2021 def _onmove(self, event):
2022 """Cursor move event handler."""
2024 def on_scroll(self, event):
2025 """Mouse scroll event handler and validator."""
2026 if not self.ignore(event):
2027 self._on_scroll(event)
2029 def _on_scroll(self, event):
2030 """Mouse scroll event handler."""
2032 def on_key_press(self, event):
2033 """Key press event handler and validator for all selection widgets."""
2034 if self.active:
2035 key = event.key or ''
2036 key = key.replace('ctrl', 'control')
2037 if key == self._state_modifier_keys['clear']:
2038 self.clear()
2039 return
2040 for (state, modifier) in self._state_modifier_keys.items():
2041 if modifier in key.split('+'):
2042 # 'rotate' is changing _state on press and is not removed
2043 # from _state when releasing
2044 if state == 'rotate':
2045 if state in self._state:
2046 self._state.discard(state)
2047 else:
2048 self._state.add(state)
2049 else:
2050 self._state.add(state)
2051 self._on_key_press(event)
2053 def _on_key_press(self, event):
2054 """Key press event handler - for widget-specific key press actions."""
2056 def on_key_release(self, event):
2057 """Key release event handler and validator."""
2058 if self.active:
2059 key = event.key or ''
2060 for (state, modifier) in self._state_modifier_keys.items():
2061 # 'rotate' is changing _state on press and is not removed
2062 # from _state when releasing
2063 if modifier in key.split('+') and state != 'rotate':
2064 self._state.discard(state)
2065 self._on_key_release(event)
2067 def _on_key_release(self, event):
2068 """Key release event handler."""
2070 def set_visible(self, visible):
2071 """Set the visibility of the selector artists."""
2072 self._visible = visible
2073 for artist in self.artists:
2074 artist.set_visible(visible)
2076 def get_visible(self):
2077 """Get the visibility of the selector artists."""
2078 return self._visible
2080 @property
2081 def visible(self):
2082 return self.get_visible()
2084 @visible.setter
2085 def visible(self, visible):
2086 _api.warn_deprecated("3.6", alternative="set_visible")
2087 self.set_visible(visible)
2089 def clear(self):
2090 """Clear the selection and set the selector ready to make a new one."""
2091 self._clear_without_update()
2092 self.update()
2094 def _clear_without_update(self):
2095 self._selection_completed = False
2096 self.set_visible(False)
2098 @property
2099 def artists(self):
2100 """Tuple of the artists of the selector."""
2101 handles_artists = getattr(self, '_handles_artists', ())
2102 return (self._selection_artist,) + handles_artists
2104 def set_props(self, **props):
2105 """
2106 Set the properties of the selector artist. See the `props` argument
2107 in the selector docstring to know which properties are supported.
2108 """
2109 artist = self._selection_artist
2110 props = cbook.normalize_kwargs(props, artist)
2111 artist.set(**props)
2112 if self.useblit:
2113 self.update()
2114 self._props.update(props)
2116 def set_handle_props(self, **handle_props):
2117 """
2118 Set the properties of the handles selector artist. See the
2119 `handle_props` argument in the selector docstring to know which
2120 properties are supported.
2121 """
2122 if not hasattr(self, '_handles_artists'):
2123 raise NotImplementedError("This selector doesn't have handles.")
2125 artist = self._handles_artists[0]
2126 handle_props = cbook.normalize_kwargs(handle_props, artist)
2127 for handle in self._handles_artists:
2128 handle.set(**handle_props)
2129 if self.useblit:
2130 self.update()
2131 self._handle_props.update(handle_props)
2133 def _validate_state(self, state):
2134 supported_state = [
2135 key for key, value in self._state_modifier_keys.items()
2136 if key != 'clear' and value != 'not-applicable'
2137 ]
2138 _api.check_in_list(supported_state, state=state)
2140 def add_state(self, state):
2141 """
2142 Add a state to define the widget's behavior. See the
2143 `state_modifier_keys` parameters for details.
2145 Parameters
2146 ----------
2147 state : str
2148 Must be a supported state of the selector. See the
2149 `state_modifier_keys` parameters for details.
2151 Raises
2152 ------
2153 ValueError
2154 When the state is not supported by the selector.
2156 """
2157 self._validate_state(state)
2158 self._state.add(state)
2160 def remove_state(self, state):
2161 """
2162 Remove a state to define the widget's behavior. See the
2163 `state_modifier_keys` parameters for details.
2165 Parameters
2166 ----------
2167 value : str
2168 Must be a supported state of the selector. See the
2169 `state_modifier_keys` parameters for details.
2171 Raises
2172 ------
2173 ValueError
2174 When the state is not supported by the selector.
2176 """
2177 self._validate_state(state)
2178 self._state.remove(state)
2181class SpanSelector(_SelectorWidget):
2182 """
2183 Visually select a min/max range on a single axis and call a function with
2184 those values.
2186 To guarantee that the selector remains responsive, keep a reference to it.
2188 In order to turn off the SpanSelector, set ``span_selector.active`` to
2189 False. To turn it back on, set it to True.
2191 Press and release events triggered at the same coordinates outside the
2192 selection will clear the selector, except when
2193 ``ignore_event_outside=True``.
2195 Parameters
2196 ----------
2197 ax : `matplotlib.axes.Axes`
2199 onselect : callable
2200 A callback function that is called after a release event and the
2201 selection is created, changed or removed.
2202 It must have the signature::
2204 def on_select(min: float, max: float) -> Any
2206 direction : {"horizontal", "vertical"}
2207 The direction along which to draw the span selector.
2209 minspan : float, default: 0
2210 If selection is less than or equal to *minspan*, the selection is
2211 removed (when already existing) or cancelled.
2213 useblit : bool, default: False
2214 If True, use the backend-dependent blitting features for faster
2215 canvas updates. See the tutorial :doc:`/tutorials/advanced/blitting`
2216 for details.
2218 props : dict, optional
2219 Dictionary of `matplotlib.patches.Patch` properties.
2220 Default:
2222 ``dict(facecolor='red', alpha=0.5)``
2224 onmove_callback : func(min, max), min/max are floats, default: None
2225 Called on mouse move while the span is being selected.
2227 span_stays : bool, default: False
2228 If True, the span stays visible after the mouse is released.
2229 Deprecated, use *interactive* instead.
2231 interactive : bool, default: False
2232 Whether to draw a set of handles that allow interaction with the
2233 widget after it is drawn.
2235 button : `.MouseButton` or list of `.MouseButton`, default: all buttons
2236 The mouse buttons which activate the span selector.
2238 handle_props : dict, default: None
2239 Properties of the handle lines at the edges of the span. Only used
2240 when *interactive* is True. See `matplotlib.lines.Line2D` for valid
2241 properties.
2243 grab_range : float, default: 10
2244 Distance in pixels within which the interactive tool handles can be
2245 activated.
2247 state_modifier_keys : dict, optional
2248 Keyboard modifiers which affect the widget's behavior. Values
2249 amend the defaults, which are:
2251 - "clear": Clear the current shape, default: "escape".
2253 drag_from_anywhere : bool, default: False
2254 If `True`, the widget can be moved by clicking anywhere within
2255 its bounds.
2257 ignore_event_outside : bool, default: False
2258 If `True`, the event triggered outside the span selector will be
2259 ignored.
2261 snap_values : 1D array-like, optional
2262 Snap the selector edges to the given values.
2264 Examples
2265 --------
2266 >>> import matplotlib.pyplot as plt
2267 >>> import matplotlib.widgets as mwidgets
2268 >>> fig, ax = plt.subplots()
2269 >>> ax.plot([1, 2, 3], [10, 50, 100])
2270 >>> def onselect(vmin, vmax):
2271 ... print(vmin, vmax)
2272 >>> span = mwidgets.SpanSelector(ax, onselect, 'horizontal',
2273 ... props=dict(facecolor='blue', alpha=0.5))
2274 >>> fig.show()
2276 See also: :doc:`/gallery/widgets/span_selector`
2277 """
2279 @_api.rename_parameter("3.5", "rectprops", "props")
2280 @_api.rename_parameter("3.5", "span_stays", "interactive")
2281 def __init__(self, ax, onselect, direction, minspan=0, useblit=False,
2282 props=None, onmove_callback=None, interactive=False,
2283 button=None, handle_props=None, grab_range=10,
2284 state_modifier_keys=None, drag_from_anywhere=False,
2285 ignore_event_outside=False, snap_values=None):
2287 if state_modifier_keys is None:
2288 state_modifier_keys = dict(clear='escape',
2289 square='not-applicable',
2290 center='not-applicable',
2291 rotate='not-applicable')
2292 super().__init__(ax, onselect, useblit=useblit, button=button,
2293 state_modifier_keys=state_modifier_keys)
2295 if props is None:
2296 props = dict(facecolor='red', alpha=0.5)
2298 props['animated'] = self.useblit
2300 self.direction = direction
2301 self._extents_on_press = None
2302 self.snap_values = snap_values
2304 # self._pressv is deprecated and we don't use it internally anymore
2305 # but we maintain it until it is removed
2306 self._pressv = None
2308 self._props = props
2309 self.onmove_callback = onmove_callback
2310 self.minspan = minspan
2312 self.grab_range = grab_range
2313 self._interactive = interactive
2314 self._edge_handles = None
2315 self.drag_from_anywhere = drag_from_anywhere
2316 self.ignore_event_outside = ignore_event_outside
2318 # Reset canvas so that `new_axes` connects events.
2319 self.canvas = None
2320 self.new_axes(ax)
2322 # Setup handles
2323 self._handle_props = {
2324 'color': props.get('facecolor', 'r'),
2325 **cbook.normalize_kwargs(handle_props, Line2D)}
2327 if self._interactive:
2328 self._edge_order = ['min', 'max']
2329 self._setup_edge_handles(self._handle_props)
2331 self._active_handle = None
2333 # prev attribute is deprecated but we still need to maintain it
2334 self._prev = (0, 0)
2336 rect = _api.deprecated("3.5")(
2337 property(lambda self: self._selection_artist)
2338 )
2340 rectprops = _api.deprecated("3.5")(
2341 property(lambda self: self._props)
2342 )
2344 active_handle = _api.deprecate_privatize_attribute("3.5")
2346 pressv = _api.deprecate_privatize_attribute("3.5")
2348 span_stays = _api.deprecated("3.5")(
2349 property(lambda self: self._interactive)
2350 )
2352 prev = _api.deprecate_privatize_attribute("3.5")
2354 def new_axes(self, ax):
2355 """Set SpanSelector to operate on a new Axes."""
2356 self.ax = ax
2357 if self.canvas is not ax.figure.canvas:
2358 if self.canvas is not None:
2359 self.disconnect_events()
2361 self.canvas = ax.figure.canvas
2362 self.connect_default_events()
2364 # Reset
2365 self._selection_completed = False
2367 if self.direction == 'horizontal':
2368 trans = ax.get_xaxis_transform()
2369 w, h = 0, 1
2370 else:
2371 trans = ax.get_yaxis_transform()
2372 w, h = 1, 0
2373 rect_artist = Rectangle((0, 0), w, h,
2374 transform=trans,
2375 visible=False,
2376 **self._props)
2378 self.ax.add_patch(rect_artist)
2379 self._selection_artist = rect_artist
2381 def _setup_edge_handles(self, props):
2382 # Define initial position using the axis bounds to keep the same bounds
2383 if self.direction == 'horizontal':
2384 positions = self.ax.get_xbound()
2385 else:
2386 positions = self.ax.get_ybound()
2387 self._edge_handles = ToolLineHandles(self.ax, positions,
2388 direction=self.direction,
2389 line_props=props,
2390 useblit=self.useblit)
2392 @property
2393 def _handles_artists(self):
2394 if self._edge_handles is not None:
2395 return self._edge_handles.artists
2396 else:
2397 return ()
2399 def _set_cursor(self, enabled):
2400 """Update the canvas cursor based on direction of the selector."""
2401 if enabled:
2402 cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL
2403 if self.direction == 'horizontal' else
2404 backend_tools.Cursors.RESIZE_VERTICAL)
2405 else:
2406 cursor = backend_tools.Cursors.POINTER
2408 self.ax.figure.canvas.set_cursor(cursor)
2410 def connect_default_events(self):
2411 # docstring inherited
2412 super().connect_default_events()
2413 if getattr(self, '_interactive', False):
2414 self.connect_event('motion_notify_event', self._hover)
2416 def _press(self, event):
2417 """Button press event handler."""
2418 self._set_cursor(True)
2419 if self._interactive and self._selection_artist.get_visible():
2420 self._set_active_handle(event)
2421 else:
2422 self._active_handle = None
2424 if self._active_handle is None or not self._interactive:
2425 # Clear previous rectangle before drawing new rectangle.
2426 self.update()
2428 v = event.xdata if self.direction == 'horizontal' else event.ydata
2429 # self._pressv and self._prev are deprecated but we still need to
2430 # maintain them
2431 self._pressv = v
2432 self._prev = self._get_data(event)
2434 if self._active_handle is None and not self.ignore_event_outside:
2435 # when the press event outside the span, we initially set the
2436 # visibility to False and extents to (v, v)
2437 # update will be called when setting the extents
2438 self._visible = False
2439 self.extents = v, v
2440 # We need to set the visibility back, so the span selector will be
2441 # drawn when necessary (span width > 0)
2442 self._visible = True
2443 else:
2444 self.set_visible(True)
2446 return False
2448 @property
2449 def direction(self):
2450 """Direction of the span selector: 'vertical' or 'horizontal'."""
2451 return self._direction
2453 @direction.setter
2454 def direction(self, direction):
2455 """Set the direction of the span selector."""
2456 _api.check_in_list(['horizontal', 'vertical'], direction=direction)
2457 if hasattr(self, '_direction') and direction != self._direction:
2458 # remove previous artists
2459 self._selection_artist.remove()
2460 if self._interactive:
2461 self._edge_handles.remove()
2462 self._direction = direction
2463 self.new_axes(self.ax)
2464 if self._interactive:
2465 self._setup_edge_handles(self._handle_props)
2466 else:
2467 self._direction = direction
2469 def _release(self, event):
2470 """Button release event handler."""
2471 self._set_cursor(False)
2472 # self._pressv is deprecated but we still need to maintain it
2473 self._pressv = None
2475 if not self._interactive:
2476 self._selection_artist.set_visible(False)
2478 if (self._active_handle is None and self._selection_completed and
2479 self.ignore_event_outside):
2480 return
2482 vmin, vmax = self.extents
2483 span = vmax - vmin
2485 if span <= self.minspan:
2486 # Remove span and set self._selection_completed = False
2487 self.set_visible(False)
2488 if self._selection_completed:
2489 # Call onselect, only when the span is already existing
2490 self.onselect(vmin, vmax)
2491 self._selection_completed = False
2492 else:
2493 self.onselect(vmin, vmax)
2494 self._selection_completed = True
2496 self.update()
2498 self._active_handle = None
2500 return False
2502 def _hover(self, event):
2503 """Update the canvas cursor if it's over a handle."""
2504 if self.ignore(event):
2505 return
2507 if self._active_handle is not None or not self._selection_completed:
2508 # Do nothing if button is pressed and a handle is active, which may
2509 # occur with drag_from_anywhere=True.
2510 # Do nothing if selection is not completed, which occurs when
2511 # a selector has been cleared
2512 return
2514 _, e_dist = self._edge_handles.closest(event.x, event.y)
2515 self._set_cursor(e_dist <= self.grab_range)
2517 def _onmove(self, event):
2518 """Motion notify event handler."""
2520 # self._prev are deprecated but we still need to maintain it
2521 self._prev = self._get_data(event)
2523 v = event.xdata if self.direction == 'horizontal' else event.ydata
2524 if self.direction == 'horizontal':
2525 vpress = self._eventpress.xdata
2526 else:
2527 vpress = self._eventpress.ydata
2529 # move existing span
2530 # When "dragging from anywhere", `self._active_handle` is set to 'C'
2531 # (match notation used in the RectangleSelector)
2532 if self._active_handle == 'C' and self._extents_on_press is not None:
2533 vmin, vmax = self._extents_on_press
2534 dv = v - vpress
2535 vmin += dv
2536 vmax += dv
2538 # resize an existing shape
2539 elif self._active_handle and self._active_handle != 'C':
2540 vmin, vmax = self._extents_on_press
2541 if self._active_handle == 'min':
2542 vmin = v
2543 else:
2544 vmax = v
2545 # new shape
2546 else:
2547 # Don't create a new span if there is already one when
2548 # ignore_event_outside=True
2549 if self.ignore_event_outside and self._selection_completed:
2550 return
2551 vmin, vmax = vpress, v
2552 if vmin > vmax:
2553 vmin, vmax = vmax, vmin
2555 self.extents = vmin, vmax
2557 if self.onmove_callback is not None:
2558 self.onmove_callback(vmin, vmax)
2560 return False
2562 def _draw_shape(self, vmin, vmax):
2563 if vmin > vmax:
2564 vmin, vmax = vmax, vmin
2565 if self.direction == 'horizontal':
2566 self._selection_artist.set_x(vmin)
2567 self._selection_artist.set_width(vmax - vmin)
2568 else:
2569 self._selection_artist.set_y(vmin)
2570 self._selection_artist.set_height(vmax - vmin)
2572 def _set_active_handle(self, event):
2573 """Set active handle based on the location of the mouse event."""
2574 # Note: event.xdata/ydata in data coordinates, event.x/y in pixels
2575 e_idx, e_dist = self._edge_handles.closest(event.x, event.y)
2577 # Prioritise center handle over other handles
2578 # Use 'C' to match the notation used in the RectangleSelector
2579 if 'move' in self._state:
2580 self._active_handle = 'C'
2581 elif e_dist > self.grab_range:
2582 # Not close to any handles
2583 self._active_handle = None
2584 if self.drag_from_anywhere and self._contains(event):
2585 # Check if we've clicked inside the region
2586 self._active_handle = 'C'
2587 self._extents_on_press = self.extents
2588 else:
2589 self._active_handle = None
2590 return
2591 else:
2592 # Closest to an edge handle
2593 self._active_handle = self._edge_order[e_idx]
2595 # Save coordinates of rectangle at the start of handle movement.
2596 self._extents_on_press = self.extents
2598 def _contains(self, event):
2599 """Return True if event is within the patch."""
2600 return self._selection_artist.contains(event, radius=0)[0]
2602 @staticmethod
2603 def _snap(values, snap_values):
2604 """Snap values to a given array values (snap_values)."""
2605 # take into account machine precision
2606 eps = np.min(np.abs(np.diff(snap_values))) * 1e-12
2607 return tuple(
2608 snap_values[np.abs(snap_values - v + np.sign(v) * eps).argmin()]
2609 for v in values)
2611 @property
2612 def extents(self):
2613 """Return extents of the span selector."""
2614 if self.direction == 'horizontal':
2615 vmin = self._selection_artist.get_x()
2616 vmax = vmin + self._selection_artist.get_width()
2617 else:
2618 vmin = self._selection_artist.get_y()
2619 vmax = vmin + self._selection_artist.get_height()
2620 return vmin, vmax
2622 @extents.setter
2623 def extents(self, extents):
2624 # Update displayed shape
2625 if self.snap_values is not None:
2626 extents = tuple(self._snap(extents, self.snap_values))
2627 self._draw_shape(*extents)
2628 if self._interactive:
2629 # Update displayed handles
2630 self._edge_handles.set_data(self.extents)
2631 self.set_visible(self._visible)
2632 self.update()
2635class ToolLineHandles:
2636 """
2637 Control handles for canvas tools.
2639 Parameters
2640 ----------
2641 ax : `matplotlib.axes.Axes`
2642 Matplotlib Axes where tool handles are displayed.
2643 positions : 1D array
2644 Positions of handles in data coordinates.
2645 direction : {"horizontal", "vertical"}
2646 Direction of handles, either 'vertical' or 'horizontal'
2647 line_props : dict, optional
2648 Additional line properties. See `matplotlib.lines.Line2D`.
2649 useblit : bool, default: True
2650 Whether to use blitting for faster drawing (if supported by the
2651 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
2652 for details.
2653 """
2655 def __init__(self, ax, positions, direction, line_props=None,
2656 useblit=True):
2657 self.ax = ax
2659 _api.check_in_list(['horizontal', 'vertical'], direction=direction)
2660 self._direction = direction
2662 if line_props is None:
2663 line_props = {}
2664 line_props.update({'visible': False, 'animated': useblit})
2666 line_fun = ax.axvline if self.direction == 'horizontal' else ax.axhline
2668 self._artists = [line_fun(p, **line_props) for p in positions]
2670 @property
2671 def artists(self):
2672 return tuple(self._artists)
2674 @property
2675 def positions(self):
2676 """Positions of the handle in data coordinates."""
2677 method = 'get_xdata' if self.direction == 'horizontal' else 'get_ydata'
2678 return [getattr(line, method)()[0] for line in self.artists]
2680 @property
2681 def direction(self):
2682 """Direction of the handle: 'vertical' or 'horizontal'."""
2683 return self._direction
2685 def set_data(self, positions):
2686 """
2687 Set x- or y-positions of handles, depending on if the lines are
2688 vertical or horizontal.
2690 Parameters
2691 ----------
2692 positions : tuple of length 2
2693 Set the positions of the handle in data coordinates
2694 """
2695 method = 'set_xdata' if self.direction == 'horizontal' else 'set_ydata'
2696 for line, p in zip(self.artists, positions):
2697 getattr(line, method)([p, p])
2699 def set_visible(self, value):
2700 """Set the visibility state of the handles artist."""
2701 for artist in self.artists:
2702 artist.set_visible(value)
2704 def set_animated(self, value):
2705 """Set the animated state of the handles artist."""
2706 for artist in self.artists:
2707 artist.set_animated(value)
2709 def remove(self):
2710 """Remove the handles artist from the figure."""
2711 for artist in self._artists:
2712 artist.remove()
2714 def closest(self, x, y):
2715 """
2716 Return index and pixel distance to closest handle.
2718 Parameters
2719 ----------
2720 x, y : float
2721 x, y position from which the distance will be calculated to
2722 determinate the closest handle
2724 Returns
2725 -------
2726 index, distance : index of the handle and its distance from
2727 position x, y
2728 """
2729 if self.direction == 'horizontal':
2730 p_pts = np.array([
2731 self.ax.transData.transform((p, 0))[0] for p in self.positions
2732 ])
2733 dist = abs(p_pts - x)
2734 else:
2735 p_pts = np.array([
2736 self.ax.transData.transform((0, p))[1] for p in self.positions
2737 ])
2738 dist = abs(p_pts - y)
2739 index = np.argmin(dist)
2740 return index, dist[index]
2743class ToolHandles:
2744 """
2745 Control handles for canvas tools.
2747 Parameters
2748 ----------
2749 ax : `matplotlib.axes.Axes`
2750 Matplotlib Axes where tool handles are displayed.
2751 x, y : 1D arrays
2752 Coordinates of control handles.
2753 marker : str, default: 'o'
2754 Shape of marker used to display handle. See `matplotlib.pyplot.plot`.
2755 marker_props : dict, optional
2756 Additional marker properties. See `matplotlib.lines.Line2D`.
2757 useblit : bool, default: True
2758 Whether to use blitting for faster drawing (if supported by the
2759 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
2760 for details.
2761 """
2763 def __init__(self, ax, x, y, marker='o', marker_props=None, useblit=True):
2764 self.ax = ax
2765 props = {'marker': marker, 'markersize': 7, 'markerfacecolor': 'w',
2766 'linestyle': 'none', 'alpha': 0.5, 'visible': False,
2767 'label': '_nolegend_',
2768 **cbook.normalize_kwargs(marker_props, Line2D._alias_map)}
2769 self._markers = Line2D(x, y, animated=useblit, **props)
2770 self.ax.add_line(self._markers)
2772 @property
2773 def x(self):
2774 return self._markers.get_xdata()
2776 @property
2777 def y(self):
2778 return self._markers.get_ydata()
2780 @property
2781 def artists(self):
2782 return (self._markers, )
2784 def set_data(self, pts, y=None):
2785 """Set x and y positions of handles."""
2786 if y is not None:
2787 x = pts
2788 pts = np.array([x, y])
2789 self._markers.set_data(pts)
2791 def set_visible(self, val):
2792 self._markers.set_visible(val)
2794 def set_animated(self, val):
2795 self._markers.set_animated(val)
2797 def closest(self, x, y):
2798 """Return index and pixel distance to closest index."""
2799 pts = np.column_stack([self.x, self.y])
2800 # Transform data coordinates to pixel coordinates.
2801 pts = self.ax.transData.transform(pts)
2802 diff = pts - [x, y]
2803 dist = np.hypot(*diff.T)
2804 min_index = np.argmin(dist)
2805 return min_index, dist[min_index]
2808_RECTANGLESELECTOR_PARAMETERS_DOCSTRING = \
2809 r"""
2810 Parameters
2811 ----------
2812 ax : `~matplotlib.axes.Axes`
2813 The parent axes for the widget.
2815 onselect : function
2816 A callback function that is called after a release event and the
2817 selection is created, changed or removed.
2818 It must have the signature::
2820 def onselect(eclick: MouseEvent, erelease: MouseEvent)
2822 where *eclick* and *erelease* are the mouse click and release
2823 `.MouseEvent`\s that start and complete the selection.
2825 minspanx : float, default: 0
2826 Selections with an x-span less than or equal to *minspanx* are removed
2827 (when already existing) or cancelled.
2829 minspany : float, default: 0
2830 Selections with an y-span less than or equal to *minspanx* are removed
2831 (when already existing) or cancelled.
2833 useblit : bool, default: False
2834 Whether to use blitting for faster drawing (if supported by the
2835 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
2836 for details.
2838 props : dict, optional
2839 Properties with which the __ARTIST_NAME__ is drawn. See
2840 `matplotlib.patches.Patch` for valid properties.
2841 Default:
2843 ``dict(facecolor='red', edgecolor='black', alpha=0.2, fill=True)``
2845 spancoords : {"data", "pixels"}, default: "data"
2846 Whether to interpret *minspanx* and *minspany* in data or in pixel
2847 coordinates.
2849 button : `.MouseButton`, list of `.MouseButton`, default: all buttons
2850 Button(s) that trigger rectangle selection.
2852 grab_range : float, default: 10
2853 Distance in pixels within which the interactive tool handles can be
2854 activated.
2856 handle_props : dict, optional
2857 Properties with which the interactive handles (marker artists) are
2858 drawn. See the marker arguments in `matplotlib.lines.Line2D` for valid
2859 properties. Default values are defined in ``mpl.rcParams`` except for
2860 the default value of ``markeredgecolor`` which will be the same as the
2861 ``edgecolor`` property in *props*.
2863 interactive : bool, default: False
2864 Whether to draw a set of handles that allow interaction with the
2865 widget after it is drawn.
2867 state_modifier_keys : dict, optional
2868 Keyboard modifiers which affect the widget's behavior. Values
2869 amend the defaults, which are:
2871 - "move": Move the existing shape, default: no modifier.
2872 - "clear": Clear the current shape, default: "escape".
2873 - "square": Make the shape square, default: "shift".
2874 - "center": change the shape around its center, default: "ctrl".
2875 - "rotate": Rotate the shape around its center between -45° and 45°,
2876 default: "r".
2878 "square" and "center" can be combined. The square shape can be defined
2879 in data or display coordinates as determined by the
2880 ``use_data_coordinates`` argument specified when creating the selector.
2882 drag_from_anywhere : bool, default: False
2883 If `True`, the widget can be moved by clicking anywhere within
2884 its bounds.
2886 ignore_event_outside : bool, default: False
2887 If `True`, the event triggered outside the span selector will be
2888 ignored.
2890 use_data_coordinates : bool, default: False
2891 If `True`, the "square" shape of the selector is defined in
2892 data coordinates instead of display coordinates.
2893 """
2896@_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace(
2897 '__ARTIST_NAME__', 'rectangle'))
2898class RectangleSelector(_SelectorWidget):
2899 """
2900 Select a rectangular region of an Axes.
2902 For the cursor to remain responsive you must keep a reference to it.
2904 Press and release events triggered at the same coordinates outside the
2905 selection will clear the selector, except when
2906 ``ignore_event_outside=True``.
2908 %s
2910 Examples
2911 --------
2912 >>> import matplotlib.pyplot as plt
2913 >>> import matplotlib.widgets as mwidgets
2914 >>> fig, ax = plt.subplots()
2915 >>> ax.plot([1, 2, 3], [10, 50, 100])
2916 >>> def onselect(eclick, erelease):
2917 ... print(eclick.xdata, eclick.ydata)
2918 ... print(erelease.xdata, erelease.ydata)
2919 >>> props = dict(facecolor='blue', alpha=0.5)
2920 >>> rect = mwidgets.RectangleSelector(ax, onselect, interactive=True,
2921 ... props=props)
2922 >>> fig.show()
2923 >>> rect.add_state('square')
2925 See also: :doc:`/gallery/widgets/rectangle_selector`
2926 """
2928 @_api.rename_parameter("3.5", "maxdist", "grab_range")
2929 @_api.rename_parameter("3.5", "marker_props", "handle_props")
2930 @_api.rename_parameter("3.5", "rectprops", "props")
2931 @_api.delete_parameter("3.5", "drawtype")
2932 @_api.delete_parameter("3.5", "lineprops")
2933 def __init__(self, ax, onselect, drawtype='box',
2934 minspanx=0, minspany=0, useblit=False,
2935 lineprops=None, props=None, spancoords='data',
2936 button=None, grab_range=10, handle_props=None,
2937 interactive=False, state_modifier_keys=None,
2938 drag_from_anywhere=False, ignore_event_outside=False,
2939 use_data_coordinates=False):
2940 super().__init__(ax, onselect, useblit=useblit, button=button,
2941 state_modifier_keys=state_modifier_keys,
2942 use_data_coordinates=use_data_coordinates)
2944 self._interactive = interactive
2945 self.drag_from_anywhere = drag_from_anywhere
2946 self.ignore_event_outside = ignore_event_outside
2947 self._rotation = 0.0
2948 self._aspect_ratio_correction = 1.0
2950 # State to allow the option of an interactive selector that can't be
2951 # interactively drawn. This is used in PolygonSelector as an
2952 # interactive bounding box to allow the polygon to be easily resized
2953 self._allow_creation = True
2955 if drawtype == 'none': # draw a line but make it invisible
2956 _api.warn_deprecated(
2957 "3.5", message="Support for drawtype='none' is deprecated "
2958 "since %(since)s and will be removed "
2959 "%(removal)s."
2960 "Use props=dict(visible=False) instead.")
2961 drawtype = 'line'
2962 self._visible = False
2964 if drawtype == 'box':
2965 if props is None:
2966 props = dict(facecolor='red', edgecolor='black',
2967 alpha=0.2, fill=True)
2968 props['animated'] = self.useblit
2969 self._visible = props.pop('visible', self._visible)
2970 self._props = props
2971 to_draw = self._init_shape(**self._props)
2972 self.ax.add_patch(to_draw)
2973 if drawtype == 'line':
2974 _api.warn_deprecated(
2975 "3.5", message="Support for drawtype='line' is deprecated "
2976 "since %(since)s and will be removed "
2977 "%(removal)s.")
2978 if lineprops is None:
2979 lineprops = dict(color='black', linestyle='-',
2980 linewidth=2, alpha=0.5)
2981 lineprops['animated'] = self.useblit
2982 self._props = lineprops
2983 to_draw = Line2D([0, 0], [0, 0], visible=False, **self._props)
2984 self.ax.add_line(to_draw)
2986 self._selection_artist = to_draw
2987 self._set_aspect_ratio_correction()
2989 self.minspanx = minspanx
2990 self.minspany = minspany
2992 _api.check_in_list(['data', 'pixels'], spancoords=spancoords)
2993 self.spancoords = spancoords
2994 self._drawtype = drawtype
2996 self.grab_range = grab_range
2998 if self._interactive:
2999 self._handle_props = {
3000 'markeredgecolor': (self._props or {}).get(
3001 'edgecolor', 'black'),
3002 **cbook.normalize_kwargs(handle_props, Line2D)}
3004 self._corner_order = ['SW', 'SE', 'NE', 'NW']
3005 xc, yc = self.corners
3006 self._corner_handles = ToolHandles(self.ax, xc, yc,
3007 marker_props=self._handle_props,
3008 useblit=self.useblit)
3010 self._edge_order = ['W', 'S', 'E', 'N']
3011 xe, ye = self.edge_centers
3012 self._edge_handles = ToolHandles(self.ax, xe, ye, marker='s',
3013 marker_props=self._handle_props,
3014 useblit=self.useblit)
3016 xc, yc = self.center
3017 self._center_handle = ToolHandles(self.ax, [xc], [yc], marker='s',
3018 marker_props=self._handle_props,
3019 useblit=self.useblit)
3021 self._active_handle = None
3023 self._extents_on_press = None
3025 to_draw = _api.deprecated("3.5")(
3026 property(lambda self: self._selection_artist)
3027 )
3029 drawtype = _api.deprecate_privatize_attribute("3.5")
3031 active_handle = _api.deprecate_privatize_attribute("3.5")
3033 interactive = _api.deprecate_privatize_attribute("3.5")
3035 maxdist = _api.deprecated("3.5", name="maxdist", alternative="grab_range")(
3036 property(lambda self: self.grab_range,
3037 lambda self, value: setattr(self, "grab_range", value)))
3039 @property
3040 def _handles_artists(self):
3041 return (*self._center_handle.artists, *self._corner_handles.artists,
3042 *self._edge_handles.artists)
3044 def _init_shape(self, **props):
3045 return Rectangle((0, 0), 0, 1, visible=False,
3046 rotation_point='center', **props)
3048 def _press(self, event):
3049 """Button press event handler."""
3050 # make the drawn box/line visible get the click-coordinates,
3051 # button, ...
3052 if self._interactive and self._selection_artist.get_visible():
3053 self._set_active_handle(event)
3054 else:
3055 self._active_handle = None
3057 if ((self._active_handle is None or not self._interactive) and
3058 self._allow_creation):
3059 # Clear previous rectangle before drawing new rectangle.
3060 self.update()
3062 if (self._active_handle is None and not self.ignore_event_outside and
3063 self._allow_creation):
3064 x = event.xdata
3065 y = event.ydata
3066 self._visible = False
3067 self.extents = x, x, y, y
3068 self._visible = True
3069 else:
3070 self.set_visible(True)
3072 self._extents_on_press = self.extents
3073 self._rotation_on_press = self._rotation
3074 self._set_aspect_ratio_correction()
3076 return False
3078 def _release(self, event):
3079 """Button release event handler."""
3080 if not self._interactive:
3081 self._selection_artist.set_visible(False)
3083 if (self._active_handle is None and self._selection_completed and
3084 self.ignore_event_outside):
3085 return
3087 # update the eventpress and eventrelease with the resulting extents
3088 x0, x1, y0, y1 = self.extents
3089 self._eventpress.xdata = x0
3090 self._eventpress.ydata = y0
3091 xy0 = self.ax.transData.transform([x0, y0])
3092 self._eventpress.x, self._eventpress.y = xy0
3094 self._eventrelease.xdata = x1
3095 self._eventrelease.ydata = y1
3096 xy1 = self.ax.transData.transform([x1, y1])
3097 self._eventrelease.x, self._eventrelease.y = xy1
3099 # calculate dimensions of box or line
3100 if self.spancoords == 'data':
3101 spanx = abs(self._eventpress.xdata - self._eventrelease.xdata)
3102 spany = abs(self._eventpress.ydata - self._eventrelease.ydata)
3103 elif self.spancoords == 'pixels':
3104 spanx = abs(self._eventpress.x - self._eventrelease.x)
3105 spany = abs(self._eventpress.y - self._eventrelease.y)
3106 else:
3107 _api.check_in_list(['data', 'pixels'],
3108 spancoords=self.spancoords)
3109 # check if drawn distance (if it exists) is not too small in
3110 # either x or y-direction
3111 minspanxy = (spanx <= self.minspanx or spany <= self.minspany)
3112 if (self._drawtype != 'none' and minspanxy):
3113 if self._selection_completed:
3114 # Call onselect, only when the selection is already existing
3115 self.onselect(self._eventpress, self._eventrelease)
3116 self._clear_without_update()
3117 else:
3118 self.onselect(self._eventpress, self._eventrelease)
3119 self._selection_completed = True
3121 self.update()
3122 self._active_handle = None
3123 self._extents_on_press = None
3125 return False
3127 def _onmove(self, event):
3128 """
3129 Motion notify event handler.
3131 This can do one of four things:
3132 - Translate
3133 - Rotate
3134 - Re-size
3135 - Continue the creation of a new shape
3136 """
3137 eventpress = self._eventpress
3138 # The calculations are done for rotation at zero: we apply inverse
3139 # transformation to events except when we rotate and move
3140 state = self._state
3141 rotate = ('rotate' in state and
3142 self._active_handle in self._corner_order)
3143 move = self._active_handle == 'C'
3144 resize = self._active_handle and not move
3146 if resize:
3147 inv_tr = self._get_rotation_transform().inverted()
3148 event.xdata, event.ydata = inv_tr.transform(
3149 [event.xdata, event.ydata])
3150 eventpress.xdata, eventpress.ydata = inv_tr.transform(
3151 [eventpress.xdata, eventpress.ydata]
3152 )
3154 dx = event.xdata - eventpress.xdata
3155 dy = event.ydata - eventpress.ydata
3156 # refmax is used when moving the corner handle with the square state
3157 # and is the maximum between refx and refy
3158 refmax = None
3159 if self._use_data_coordinates:
3160 refx, refy = dx, dy
3161 else:
3162 # Get dx/dy in display coordinates
3163 refx = event.x - eventpress.x
3164 refy = event.y - eventpress.y
3166 x0, x1, y0, y1 = self._extents_on_press
3167 # rotate an existing shape
3168 if rotate:
3169 # calculate angle abc
3170 a = np.array([eventpress.xdata, eventpress.ydata])
3171 b = np.array(self.center)
3172 c = np.array([event.xdata, event.ydata])
3173 angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3174 np.arctan2(a[1]-b[1], a[0]-b[0]))
3175 self.rotation = np.rad2deg(self._rotation_on_press + angle)
3177 elif resize:
3178 size_on_press = [x1 - x0, y1 - y0]
3179 center = [x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2]
3181 # Keeping the center fixed
3182 if 'center' in state:
3183 # hh, hw are half-height and half-width
3184 if 'square' in state:
3185 # when using a corner, find which reference to use
3186 if self._active_handle in self._corner_order:
3187 refmax = max(refx, refy, key=abs)
3188 if self._active_handle in ['E', 'W'] or refmax == refx:
3189 hw = event.xdata - center[0]
3190 hh = hw / self._aspect_ratio_correction
3191 else:
3192 hh = event.ydata - center[1]
3193 hw = hh * self._aspect_ratio_correction
3194 else:
3195 hw = size_on_press[0] / 2
3196 hh = size_on_press[1] / 2
3197 # cancel changes in perpendicular direction
3198 if self._active_handle in ['E', 'W'] + self._corner_order:
3199 hw = abs(event.xdata - center[0])
3200 if self._active_handle in ['N', 'S'] + self._corner_order:
3201 hh = abs(event.ydata - center[1])
3203 x0, x1, y0, y1 = (center[0] - hw, center[0] + hw,
3204 center[1] - hh, center[1] + hh)
3206 else:
3207 # change sign of relative changes to simplify calculation
3208 # Switch variables so that x1 and/or y1 are updated on move
3209 if 'W' in self._active_handle:
3210 x0 = x1
3211 if 'S' in self._active_handle:
3212 y0 = y1
3213 if self._active_handle in ['E', 'W'] + self._corner_order:
3214 x1 = event.xdata
3215 if self._active_handle in ['N', 'S'] + self._corner_order:
3216 y1 = event.ydata
3217 if 'square' in state:
3218 # when using a corner, find which reference to use
3219 if self._active_handle in self._corner_order:
3220 refmax = max(refx, refy, key=abs)
3221 if self._active_handle in ['E', 'W'] or refmax == refx:
3222 sign = np.sign(event.ydata - y0)
3223 y1 = y0 + sign * abs(x1 - x0) / \
3224 self._aspect_ratio_correction
3225 else:
3226 sign = np.sign(event.xdata - x0)
3227 x1 = x0 + sign * abs(y1 - y0) * \
3228 self._aspect_ratio_correction
3230 elif move:
3231 x0, x1, y0, y1 = self._extents_on_press
3232 dx = event.xdata - eventpress.xdata
3233 dy = event.ydata - eventpress.ydata
3234 x0 += dx
3235 x1 += dx
3236 y0 += dy
3237 y1 += dy
3239 else:
3240 # Create a new shape
3241 self._rotation = 0
3242 # Don't create a new rectangle if there is already one when
3243 # ignore_event_outside=True
3244 if ((self.ignore_event_outside and self._selection_completed) or
3245 not self._allow_creation):
3246 return
3247 center = [eventpress.xdata, eventpress.ydata]
3248 dx = (event.xdata - center[0]) / 2.
3249 dy = (event.ydata - center[1]) / 2.
3251 # square shape
3252 if 'square' in state:
3253 refmax = max(refx, refy, key=abs)
3254 if refmax == refx:
3255 dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction
3256 else:
3257 dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction
3259 # from center
3260 if 'center' in state:
3261 dx *= 2
3262 dy *= 2
3264 # from corner
3265 else:
3266 center[0] += dx
3267 center[1] += dy
3269 x0, x1, y0, y1 = (center[0] - dx, center[0] + dx,
3270 center[1] - dy, center[1] + dy)
3272 self.extents = x0, x1, y0, y1
3274 @property
3275 def _rect_bbox(self):
3276 if self._drawtype == 'box':
3277 return self._selection_artist.get_bbox().bounds
3278 else:
3279 x, y = self._selection_artist.get_data()
3280 x0, x1 = min(x), max(x)
3281 y0, y1 = min(y), max(y)
3282 return x0, y0, x1 - x0, y1 - y0
3284 def _set_aspect_ratio_correction(self):
3285 aspect_ratio = self.ax._get_aspect_ratio()
3286 if not hasattr(self._selection_artist, '_aspect_ratio_correction'):
3287 # Aspect ratio correction is not supported with deprecated
3288 # drawtype='line'. Remove this block in matplotlib 3.7
3289 self._aspect_ratio_correction = 1
3290 return
3292 self._selection_artist._aspect_ratio_correction = aspect_ratio
3293 if self._use_data_coordinates:
3294 self._aspect_ratio_correction = 1
3295 else:
3296 self._aspect_ratio_correction = aspect_ratio
3298 def _get_rotation_transform(self):
3299 aspect_ratio = self.ax._get_aspect_ratio()
3300 return Affine2D().translate(-self.center[0], -self.center[1]) \
3301 .scale(1, aspect_ratio) \
3302 .rotate(self._rotation) \
3303 .scale(1, 1 / aspect_ratio) \
3304 .translate(*self.center)
3306 @property
3307 def corners(self):
3308 """
3309 Corners of rectangle in data coordinates from lower left,
3310 moving clockwise.
3311 """
3312 x0, y0, width, height = self._rect_bbox
3313 xc = x0, x0 + width, x0 + width, x0
3314 yc = y0, y0, y0 + height, y0 + height
3315 transform = self._get_rotation_transform()
3316 coords = transform.transform(np.array([xc, yc]).T).T
3317 return coords[0], coords[1]
3319 @property
3320 def edge_centers(self):
3321 """
3322 Midpoint of rectangle edges in data coordinates from left,
3323 moving anti-clockwise.
3324 """
3325 x0, y0, width, height = self._rect_bbox
3326 w = width / 2.
3327 h = height / 2.
3328 xe = x0, x0 + w, x0 + width, x0 + w
3329 ye = y0 + h, y0, y0 + h, y0 + height
3330 transform = self._get_rotation_transform()
3331 coords = transform.transform(np.array([xe, ye]).T).T
3332 return coords[0], coords[1]
3334 @property
3335 def center(self):
3336 """Center of rectangle in data coordinates."""
3337 x0, y0, width, height = self._rect_bbox
3338 return x0 + width / 2., y0 + height / 2.
3340 @property
3341 def extents(self):
3342 """
3343 Return (xmin, xmax, ymin, ymax) in data coordinates as defined by the
3344 bounding box before rotation.
3345 """
3346 x0, y0, width, height = self._rect_bbox
3347 xmin, xmax = sorted([x0, x0 + width])
3348 ymin, ymax = sorted([y0, y0 + height])
3349 return xmin, xmax, ymin, ymax
3351 @extents.setter
3352 def extents(self, extents):
3353 # Update displayed shape
3354 self._draw_shape(extents)
3355 if self._interactive:
3356 # Update displayed handles
3357 self._corner_handles.set_data(*self.corners)
3358 self._edge_handles.set_data(*self.edge_centers)
3359 self._center_handle.set_data(*self.center)
3360 self.set_visible(self._visible)
3361 self.update()
3363 @property
3364 def rotation(self):
3365 """
3366 Rotation in degree in interval [-45°, 45°]. The rotation is limited in
3367 range to keep the implementation simple.
3368 """
3369 return np.rad2deg(self._rotation)
3371 @rotation.setter
3372 def rotation(self, value):
3373 # Restrict to a limited range of rotation [-45°, 45°] to avoid changing
3374 # order of handles
3375 if -45 <= value and value <= 45:
3376 self._rotation = np.deg2rad(value)
3377 # call extents setter to draw shape and update handles positions
3378 self.extents = self.extents
3380 draw_shape = _api.deprecate_privatize_attribute('3.5')
3382 def _draw_shape(self, extents):
3383 x0, x1, y0, y1 = extents
3384 xmin, xmax = sorted([x0, x1])
3385 ymin, ymax = sorted([y0, y1])
3386 xlim = sorted(self.ax.get_xlim())
3387 ylim = sorted(self.ax.get_ylim())
3389 xmin = max(xlim[0], xmin)
3390 ymin = max(ylim[0], ymin)
3391 xmax = min(xmax, xlim[1])
3392 ymax = min(ymax, ylim[1])
3394 if self._drawtype == 'box':
3395 self._selection_artist.set_x(xmin)
3396 self._selection_artist.set_y(ymin)
3397 self._selection_artist.set_width(xmax - xmin)
3398 self._selection_artist.set_height(ymax - ymin)
3399 self._selection_artist.set_angle(self.rotation)
3401 elif self._drawtype == 'line':
3402 self._selection_artist.set_data([xmin, xmax], [ymin, ymax])
3404 def _set_active_handle(self, event):
3405 """Set active handle based on the location of the mouse event."""
3406 # Note: event.xdata/ydata in data coordinates, event.x/y in pixels
3407 c_idx, c_dist = self._corner_handles.closest(event.x, event.y)
3408 e_idx, e_dist = self._edge_handles.closest(event.x, event.y)
3409 m_idx, m_dist = self._center_handle.closest(event.x, event.y)
3411 if 'move' in self._state:
3412 self._active_handle = 'C'
3413 # Set active handle as closest handle, if mouse click is close enough.
3414 elif m_dist < self.grab_range * 2:
3415 # Prioritise center handle over other handles
3416 self._active_handle = 'C'
3417 elif c_dist > self.grab_range and e_dist > self.grab_range:
3418 # Not close to any handles
3419 if self.drag_from_anywhere and self._contains(event):
3420 # Check if we've clicked inside the region
3421 self._active_handle = 'C'
3422 else:
3423 self._active_handle = None
3424 return
3425 elif c_dist < e_dist:
3426 # Closest to a corner handle
3427 self._active_handle = self._corner_order[c_idx]
3428 else:
3429 # Closest to an edge handle
3430 self._active_handle = self._edge_order[e_idx]
3432 def _contains(self, event):
3433 """Return True if event is within the patch."""
3434 return self._selection_artist.contains(event, radius=0)[0]
3436 @property
3437 def geometry(self):
3438 """
3439 Return an array of shape (2, 5) containing the
3440 x (``RectangleSelector.geometry[1, :]``) and
3441 y (``RectangleSelector.geometry[0, :]``) data coordinates of the four
3442 corners of the rectangle starting and ending in the top left corner.
3443 """
3444 if hasattr(self._selection_artist, 'get_verts'):
3445 xfm = self.ax.transData.inverted()
3446 y, x = xfm.transform(self._selection_artist.get_verts()).T
3447 return np.array([x, y])
3448 else:
3449 return np.array(self._selection_artist.get_data())
3452@_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace(
3453 '__ARTIST_NAME__', 'ellipse'))
3454class EllipseSelector(RectangleSelector):
3455 """
3456 Select an elliptical region of an Axes.
3458 For the cursor to remain responsive you must keep a reference to it.
3460 Press and release events triggered at the same coordinates outside the
3461 selection will clear the selector, except when
3462 ``ignore_event_outside=True``.
3464 %s
3466 Examples
3467 --------
3468 :doc:`/gallery/widgets/rectangle_selector`
3469 """
3471 draw_shape = _api.deprecate_privatize_attribute('3.5')
3473 def _init_shape(self, **props):
3474 return Ellipse((0, 0), 0, 1, visible=False, **props)
3476 def _draw_shape(self, extents):
3477 x0, x1, y0, y1 = extents
3478 xmin, xmax = sorted([x0, x1])
3479 ymin, ymax = sorted([y0, y1])
3480 center = [x0 + (x1 - x0) / 2., y0 + (y1 - y0) / 2.]
3481 a = (xmax - xmin) / 2.
3482 b = (ymax - ymin) / 2.
3484 if self._drawtype == 'box':
3485 self._selection_artist.center = center
3486 self._selection_artist.width = 2 * a
3487 self._selection_artist.height = 2 * b
3488 self._selection_artist.angle = self.rotation
3489 else:
3490 rad = np.deg2rad(np.arange(31) * 12)
3491 x = a * np.cos(rad) + center[0]
3492 y = b * np.sin(rad) + center[1]
3493 self._selection_artist.set_data(x, y)
3495 @property
3496 def _rect_bbox(self):
3497 if self._drawtype == 'box':
3498 x, y = self._selection_artist.center
3499 width = self._selection_artist.width
3500 height = self._selection_artist.height
3501 return x - width / 2., y - height / 2., width, height
3502 else:
3503 x, y = self._selection_artist.get_data()
3504 x0, x1 = min(x), max(x)
3505 y0, y1 = min(y), max(y)
3506 return x0, y0, x1 - x0, y1 - y0
3509class LassoSelector(_SelectorWidget):
3510 """
3511 Selection curve of an arbitrary shape.
3513 For the selector to remain responsive you must keep a reference to it.
3515 The selected path can be used in conjunction with `~.Path.contains_point`
3516 to select data points from an image.
3518 In contrast to `Lasso`, `LassoSelector` is written with an interface
3519 similar to `RectangleSelector` and `SpanSelector`, and will continue to
3520 interact with the Axes until disconnected.
3522 Example usage::
3524 ax = plt.subplot()
3525 ax.plot(x, y)
3527 def onselect(verts):
3528 print(verts)
3529 lasso = LassoSelector(ax, onselect)
3531 Parameters
3532 ----------
3533 ax : `~matplotlib.axes.Axes`
3534 The parent Axes for the widget.
3535 onselect : function
3536 Whenever the lasso is released, the *onselect* function is called and
3537 passed the vertices of the selected path.
3538 useblit : bool, default: True
3539 Whether to use blitting for faster drawing (if supported by the
3540 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
3541 for details.
3542 props : dict, optional
3543 Properties with which the line is drawn, see `matplotlib.lines.Line2D`
3544 for valid properties. Default values are defined in ``mpl.rcParams``.
3545 button : `.MouseButton` or list of `.MouseButton`, optional
3546 The mouse buttons used for rectangle selection. Default is ``None``,
3547 which corresponds to all buttons.
3548 """
3550 @_api.rename_parameter("3.5", "lineprops", "props")
3551 def __init__(self, ax, onselect=None, useblit=True, props=None,
3552 button=None):
3553 super().__init__(ax, onselect, useblit=useblit, button=button)
3554 self.verts = None
3555 if props is None:
3556 props = dict()
3557 # self.useblit may be != useblit, if the canvas doesn't support blit.
3558 props.update(animated=self.useblit, visible=False)
3559 line = Line2D([], [], **props)
3560 self.ax.add_line(line)
3561 self._selection_artist = line
3563 @_api.deprecated("3.5", alternative="press")
3564 def onpress(self, event):
3565 self.press(event)
3567 def _press(self, event):
3568 self.verts = [self._get_data(event)]
3569 self._selection_artist.set_visible(True)
3571 @_api.deprecated("3.5", alternative="release")
3572 def onrelease(self, event):
3573 self.release(event)
3575 def _release(self, event):
3576 if self.verts is not None:
3577 self.verts.append(self._get_data(event))
3578 self.onselect(self.verts)
3579 self._selection_artist.set_data([[], []])
3580 self._selection_artist.set_visible(False)
3581 self.verts = None
3583 def _onmove(self, event):
3584 if self.verts is None:
3585 return
3586 self.verts.append(self._get_data(event))
3587 self._selection_artist.set_data(list(zip(*self.verts)))
3589 self.update()
3592class PolygonSelector(_SelectorWidget):
3593 """
3594 Select a polygon region of an Axes.
3596 Place vertices with each mouse click, and make the selection by completing
3597 the polygon (clicking on the first vertex). Once drawn individual vertices
3598 can be moved by clicking and dragging with the left mouse button, or
3599 removed by clicking the right mouse button.
3601 In addition, the following modifier keys can be used:
3603 - Hold *ctrl* and click and drag a vertex to reposition it before the
3604 polygon has been completed.
3605 - Hold the *shift* key and click and drag anywhere in the Axes to move
3606 all vertices.
3607 - Press the *esc* key to start a new polygon.
3609 For the selector to remain responsive you must keep a reference to it.
3611 Parameters
3612 ----------
3613 ax : `~matplotlib.axes.Axes`
3614 The parent Axes for the widget.
3616 onselect : function
3617 When a polygon is completed or modified after completion,
3618 the *onselect* function is called and passed a list of the vertices as
3619 ``(xdata, ydata)`` tuples.
3621 useblit : bool, default: False
3622 Whether to use blitting for faster drawing (if supported by the
3623 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
3624 for details.
3626 props : dict, optional
3627 Properties with which the line is drawn, see `matplotlib.lines.Line2D`
3628 for valid properties.
3629 Default:
3631 ``dict(color='k', linestyle='-', linewidth=2, alpha=0.5)``
3633 handle_props : dict, optional
3634 Artist properties for the markers drawn at the vertices of the polygon.
3635 See the marker arguments in `matplotlib.lines.Line2D` for valid
3636 properties. Default values are defined in ``mpl.rcParams`` except for
3637 the default value of ``markeredgecolor`` which will be the same as the
3638 ``color`` property in *props*.
3640 grab_range : float, default: 10
3641 A vertex is selected (to complete the polygon or to move a vertex) if
3642 the mouse click is within *grab_range* pixels of the vertex.
3644 draw_bounding_box : bool, optional
3645 If `True`, a bounding box will be drawn around the polygon selector
3646 once it is complete. This box can be used to move and resize the
3647 selector.
3649 box_handle_props : dict, optional
3650 Properties to set for the box handles. See the documentation for the
3651 *handle_props* argument to `RectangleSelector` for more info.
3653 box_props : dict, optional
3654 Properties to set for the box. See the documentation for the *props*
3655 argument to `RectangleSelector` for more info.
3657 Examples
3658 --------
3659 :doc:`/gallery/widgets/polygon_selector_simple`
3660 :doc:`/gallery/widgets/polygon_selector_demo`
3662 Notes
3663 -----
3664 If only one point remains after removing points, the selector reverts to an
3665 incomplete state and you can start drawing a new polygon from the existing
3666 point.
3667 """
3669 @_api.rename_parameter("3.5", "lineprops", "props")
3670 @_api.rename_parameter("3.5", "markerprops", "handle_props")
3671 @_api.rename_parameter("3.5", "vertex_select_radius", "grab_range")
3672 def __init__(self, ax, onselect, useblit=False,
3673 props=None, handle_props=None, grab_range=10, *,
3674 draw_bounding_box=False, box_handle_props=None,
3675 box_props=None):
3676 # The state modifiers 'move', 'square', and 'center' are expected by
3677 # _SelectorWidget but are not supported by PolygonSelector
3678 # Note: could not use the existing 'move' state modifier in-place of
3679 # 'move_all' because _SelectorWidget automatically discards 'move'
3680 # from the state on button release.
3681 state_modifier_keys = dict(clear='escape', move_vertex='control',
3682 move_all='shift', move='not-applicable',
3683 square='not-applicable',
3684 center='not-applicable',
3685 rotate='not-applicable')
3686 super().__init__(ax, onselect, useblit=useblit,
3687 state_modifier_keys=state_modifier_keys)
3689 self._xys = [(0, 0)]
3691 if props is None:
3692 props = dict(color='k', linestyle='-', linewidth=2, alpha=0.5)
3693 props['animated'] = self.useblit
3694 self._props = props
3695 self._selection_artist = line = Line2D([], [], **self._props)
3696 self.ax.add_line(line)
3698 if handle_props is None:
3699 handle_props = dict(markeredgecolor='k',
3700 markerfacecolor=self._props.get('color', 'k'))
3701 self._handle_props = handle_props
3702 self._polygon_handles = ToolHandles(self.ax, [], [],
3703 useblit=self.useblit,
3704 marker_props=self._handle_props)
3706 self._active_handle_idx = -1
3707 self.grab_range = grab_range
3709 self.set_visible(True)
3710 self._draw_box = draw_bounding_box
3711 self._box = None
3713 if box_handle_props is None:
3714 box_handle_props = {}
3715 self._box_handle_props = self._handle_props.update(box_handle_props)
3716 self._box_props = box_props
3718 def _get_bbox(self):
3719 return self._selection_artist.get_bbox()
3721 def _add_box(self):
3722 self._box = RectangleSelector(self.ax,
3723 onselect=lambda *args, **kwargs: None,
3724 useblit=self.useblit,
3725 grab_range=self.grab_range,
3726 handle_props=self._box_handle_props,
3727 props=self._box_props,
3728 interactive=True)
3729 self._box._state_modifier_keys.pop('rotate')
3730 self._box.connect_event('motion_notify_event', self._scale_polygon)
3731 self._update_box()
3732 # Set state that prevents the RectangleSelector from being created
3733 # by the user
3734 self._box._allow_creation = False
3735 self._box._selection_completed = True
3736 self._draw_polygon()
3738 def _remove_box(self):
3739 if self._box is not None:
3740 self._box.set_visible(False)
3741 self._box = None
3743 def _update_box(self):
3744 # Update selection box extents to the extents of the polygon
3745 if self._box is not None:
3746 bbox = self._get_bbox()
3747 self._box.extents = [bbox.x0, bbox.x1, bbox.y0, bbox.y1]
3748 # Save a copy
3749 self._old_box_extents = self._box.extents
3751 def _scale_polygon(self, event):
3752 """
3753 Scale the polygon selector points when the bounding box is moved or
3754 scaled.
3756 This is set as a callback on the bounding box RectangleSelector.
3757 """
3758 if not self._selection_completed:
3759 return
3761 if self._old_box_extents == self._box.extents:
3762 return
3764 # Create transform from old box to new box
3765 x1, y1, w1, h1 = self._box._rect_bbox
3766 old_bbox = self._get_bbox()
3767 t = (transforms.Affine2D()
3768 .translate(-old_bbox.x0, -old_bbox.y0)
3769 .scale(1 / old_bbox.width, 1 / old_bbox.height)
3770 .scale(w1, h1)
3771 .translate(x1, y1))
3773 # Update polygon verts. Must be a list of tuples for consistency.
3774 new_verts = [(x, y) for x, y in t.transform(np.array(self.verts))]
3775 self._xys = [*new_verts, new_verts[0]]
3776 self._draw_polygon()
3777 self._old_box_extents = self._box.extents
3779 line = _api.deprecated("3.5")(
3780 property(lambda self: self._selection_artist)
3781 )
3783 vertex_select_radius = _api.deprecated("3.5", name="vertex_select_radius",
3784 alternative="grab_range")(
3785 property(lambda self: self.grab_range,
3786 lambda self, value: setattr(self, "grab_range", value))
3787 )
3789 @property
3790 def _handles_artists(self):
3791 return self._polygon_handles.artists
3793 def _remove_vertex(self, i):
3794 """Remove vertex with index i."""
3795 if (len(self._xys) > 2 and
3796 self._selection_completed and
3797 i in (0, len(self._xys) - 1)):
3798 # If selecting the first or final vertex, remove both first and
3799 # last vertex as they are the same for a closed polygon
3800 self._xys.pop(0)
3801 self._xys.pop(-1)
3802 # Close the polygon again by appending the new first vertex to the
3803 # end
3804 self._xys.append(self._xys[0])
3805 else:
3806 self._xys.pop(i)
3807 if len(self._xys) <= 2:
3808 # If only one point left, return to incomplete state to let user
3809 # start drawing again
3810 self._selection_completed = False
3811 self._remove_box()
3813 def _press(self, event):
3814 """Button press event handler."""
3815 # Check for selection of a tool handle.
3816 if ((self._selection_completed or 'move_vertex' in self._state)
3817 and len(self._xys) > 0):
3818 h_idx, h_dist = self._polygon_handles.closest(event.x, event.y)
3819 if h_dist < self.grab_range:
3820 self._active_handle_idx = h_idx
3821 # Save the vertex positions at the time of the press event (needed to
3822 # support the 'move_all' state modifier).
3823 self._xys_at_press = self._xys.copy()
3825 def _release(self, event):
3826 """Button release event handler."""
3827 # Release active tool handle.
3828 if self._active_handle_idx >= 0:
3829 if event.button == 3:
3830 self._remove_vertex(self._active_handle_idx)
3831 self._draw_polygon()
3832 self._active_handle_idx = -1
3834 # Complete the polygon.
3835 elif len(self._xys) > 3 and self._xys[-1] == self._xys[0]:
3836 self._selection_completed = True
3837 if self._draw_box and self._box is None:
3838 self._add_box()
3840 # Place new vertex.
3841 elif (not self._selection_completed
3842 and 'move_all' not in self._state
3843 and 'move_vertex' not in self._state):
3844 self._xys.insert(-1, (event.xdata, event.ydata))
3846 if self._selection_completed:
3847 self.onselect(self.verts)
3849 def onmove(self, event):
3850 """Cursor move event handler and validator."""
3851 # Method overrides _SelectorWidget.onmove because the polygon selector
3852 # needs to process the move callback even if there is no button press.
3853 # _SelectorWidget.onmove include logic to ignore move event if
3854 # _eventpress is None.
3855 if not self.ignore(event):
3856 event = self._clean_event(event)
3857 self._onmove(event)
3858 return True
3859 return False
3861 def _onmove(self, event):
3862 """Cursor move event handler."""
3863 # Move the active vertex (ToolHandle).
3864 if self._active_handle_idx >= 0:
3865 idx = self._active_handle_idx
3866 self._xys[idx] = event.xdata, event.ydata
3867 # Also update the end of the polygon line if the first vertex is
3868 # the active handle and the polygon is completed.
3869 if idx == 0 and self._selection_completed:
3870 self._xys[-1] = event.xdata, event.ydata
3872 # Move all vertices.
3873 elif 'move_all' in self._state and self._eventpress:
3874 dx = event.xdata - self._eventpress.xdata
3875 dy = event.ydata - self._eventpress.ydata
3876 for k in range(len(self._xys)):
3877 x_at_press, y_at_press = self._xys_at_press[k]
3878 self._xys[k] = x_at_press + dx, y_at_press + dy
3880 # Do nothing if completed or waiting for a move.
3881 elif (self._selection_completed
3882 or 'move_vertex' in self._state or 'move_all' in self._state):
3883 return
3885 # Position pending vertex.
3886 else:
3887 # Calculate distance to the start vertex.
3888 x0, y0 = \
3889 self._selection_artist.get_transform().transform(self._xys[0])
3890 v0_dist = np.hypot(x0 - event.x, y0 - event.y)
3891 # Lock on to the start vertex if near it and ready to complete.
3892 if len(self._xys) > 3 and v0_dist < self.grab_range:
3893 self._xys[-1] = self._xys[0]
3894 else:
3895 self._xys[-1] = event.xdata, event.ydata
3897 self._draw_polygon()
3899 def _on_key_press(self, event):
3900 """Key press event handler."""
3901 # Remove the pending vertex if entering the 'move_vertex' or
3902 # 'move_all' mode
3903 if (not self._selection_completed
3904 and ('move_vertex' in self._state or
3905 'move_all' in self._state)):
3906 self._xys.pop()
3907 self._draw_polygon()
3909 def _on_key_release(self, event):
3910 """Key release event handler."""
3911 # Add back the pending vertex if leaving the 'move_vertex' or
3912 # 'move_all' mode (by checking the released key)
3913 if (not self._selection_completed
3914 and
3915 (event.key == self._state_modifier_keys.get('move_vertex')
3916 or event.key == self._state_modifier_keys.get('move_all'))):
3917 self._xys.append((event.xdata, event.ydata))
3918 self._draw_polygon()
3919 # Reset the polygon if the released key is the 'clear' key.
3920 elif event.key == self._state_modifier_keys.get('clear'):
3921 event = self._clean_event(event)
3922 self._xys = [(event.xdata, event.ydata)]
3923 self._selection_completed = False
3924 self._remove_box()
3925 self.set_visible(True)
3927 def _draw_polygon(self):
3928 """Redraw the polygon based on the new vertex positions."""
3929 xs, ys = zip(*self._xys) if self._xys else ([], [])
3930 self._selection_artist.set_data(xs, ys)
3931 self._update_box()
3932 # Only show one tool handle at the start and end vertex of the polygon
3933 # if the polygon is completed or the user is locked on to the start
3934 # vertex.
3935 if (self._selection_completed
3936 or (len(self._xys) > 3
3937 and self._xys[-1] == self._xys[0])):
3938 self._polygon_handles.set_data(xs[:-1], ys[:-1])
3939 else:
3940 self._polygon_handles.set_data(xs, ys)
3941 self.update()
3943 @property
3944 def verts(self):
3945 """The polygon vertices, as a list of ``(x, y)`` pairs."""
3946 return self._xys[:-1]
3948 @verts.setter
3949 def verts(self, xys):
3950 """
3951 Set the polygon vertices.
3953 This will remove any preexisting vertices, creating a complete polygon
3954 with the new vertices.
3955 """
3956 self._xys = [*xys, xys[0]]
3957 self._selection_completed = True
3958 self.set_visible(True)
3959 if self._draw_box and self._box is None:
3960 self._add_box()
3961 self._draw_polygon()
3964class Lasso(AxesWidget):
3965 """
3966 Selection curve of an arbitrary shape.
3968 The selected path can be used in conjunction with
3969 `~matplotlib.path.Path.contains_point` to select data points from an image.
3971 Unlike `LassoSelector`, this must be initialized with a starting
3972 point *xy*, and the `Lasso` events are destroyed upon release.
3974 Parameters
3975 ----------
3976 ax : `~matplotlib.axes.Axes`
3977 The parent Axes for the widget.
3978 xy : (float, float)
3979 Coordinates of the start of the lasso.
3980 useblit : bool, default: True
3981 Whether to use blitting for faster drawing (if supported by the
3982 backend). See the tutorial :doc:`/tutorials/advanced/blitting`
3983 for details.
3984 callback : callable
3985 Whenever the lasso is released, the *callback* function is called and
3986 passed the vertices of the selected path.
3987 """
3989 def __init__(self, ax, xy, callback=None, useblit=True):
3990 super().__init__(ax)
3992 self.useblit = useblit and self.canvas.supports_blit
3993 if self.useblit:
3994 self.background = self.canvas.copy_from_bbox(self.ax.bbox)
3996 x, y = xy
3997 self.verts = [(x, y)]
3998 self.line = Line2D([x], [y], linestyle='-', color='black', lw=2)
3999 self.ax.add_line(self.line)
4000 self.callback = callback
4001 self.connect_event('button_release_event', self.onrelease)
4002 self.connect_event('motion_notify_event', self.onmove)
4004 def onrelease(self, event):
4005 if self.ignore(event):
4006 return
4007 if self.verts is not None:
4008 self.verts.append((event.xdata, event.ydata))
4009 if len(self.verts) > 2:
4010 self.callback(self.verts)
4011 self.ax.lines.remove(self.line)
4012 self.verts = None
4013 self.disconnect_events()
4015 def onmove(self, event):
4016 if self.ignore(event):
4017 return
4018 if self.verts is None:
4019 return
4020 if event.inaxes != self.ax:
4021 return
4022 if event.button != 1:
4023 return
4024 self.verts.append((event.xdata, event.ydata))
4026 self.line.set_data(list(zip(*self.verts)))
4028 if self.useblit:
4029 self.canvas.restore_region(self.background)
4030 self.ax.draw_artist(self.line)
4031 self.canvas.blit(self.ax.bbox)
4032 else:
4033 self.canvas.draw_idle()