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