1from collections.abc import MutableMapping
2import functools
3
4import numpy as np
5
6import matplotlib as mpl
7from matplotlib import _api, _docstring
8from matplotlib.artist import allow_rasterization
9import matplotlib.transforms as mtransforms
10import matplotlib.patches as mpatches
11import matplotlib.path as mpath
12
13
14class Spine(mpatches.Patch):
15 """
16 An axis spine -- the line noting the data area boundaries.
17
18 Spines are the lines connecting the axis tick marks and noting the
19 boundaries of the data area. They can be placed at arbitrary
20 positions. See `~.Spine.set_position` for more information.
21
22 The default position is ``('outward', 0)``.
23
24 Spines are subclasses of `.Patch`, and inherit much of their behavior.
25
26 Spines draw a line, a circle, or an arc depending on if
27 `~.Spine.set_patch_line`, `~.Spine.set_patch_circle`, or
28 `~.Spine.set_patch_arc` has been called. Line-like is the default.
29
30 For examples see :ref:`spines_examples`.
31 """
32 def __str__(self):
33 return "Spine"
34
35 @_docstring.dedent_interpd
36 def __init__(self, axes, spine_type, path, **kwargs):
37 """
38 Parameters
39 ----------
40 axes : `~matplotlib.axes.Axes`
41 The `~.axes.Axes` instance containing the spine.
42 spine_type : str
43 The spine type.
44 path : `~matplotlib.path.Path`
45 The `.Path` instance used to draw the spine.
46
47 Other Parameters
48 ----------------
49 **kwargs
50 Valid keyword arguments are:
51
52 %(Patch:kwdoc)s
53 """
54 super().__init__(**kwargs)
55 self.axes = axes
56 self.set_figure(self.axes.figure)
57 self.spine_type = spine_type
58 self.set_facecolor('none')
59 self.set_edgecolor(mpl.rcParams['axes.edgecolor'])
60 self.set_linewidth(mpl.rcParams['axes.linewidth'])
61 self.set_capstyle('projecting')
62 self.axis = None
63
64 self.set_zorder(2.5)
65 self.set_transform(self.axes.transData) # default transform
66
67 self._bounds = None # default bounds
68
69 # Defer initial position determination. (Not much support for
70 # non-rectangular axes is currently implemented, and this lets
71 # them pass through the spines machinery without errors.)
72 self._position = None
73 _api.check_isinstance(mpath.Path, path=path)
74 self._path = path
75
76 # To support drawing both linear and circular spines, this
77 # class implements Patch behavior three ways. If
78 # self._patch_type == 'line', behave like a mpatches.PathPatch
79 # instance. If self._patch_type == 'circle', behave like a
80 # mpatches.Ellipse instance. If self._patch_type == 'arc', behave like
81 # a mpatches.Arc instance.
82 self._patch_type = 'line'
83
84 # Behavior copied from mpatches.Ellipse:
85 # Note: This cannot be calculated until this is added to an Axes
86 self._patch_transform = mtransforms.IdentityTransform()
87
88 def set_patch_arc(self, center, radius, theta1, theta2):
89 """Set the spine to be arc-like."""
90 self._patch_type = 'arc'
91 self._center = center
92 self._width = radius * 2
93 self._height = radius * 2
94 self._theta1 = theta1
95 self._theta2 = theta2
96 self._path = mpath.Path.arc(theta1, theta2)
97 # arc drawn on axes transform
98 self.set_transform(self.axes.transAxes)
99 self.stale = True
100
101 def set_patch_circle(self, center, radius):
102 """Set the spine to be circular."""
103 self._patch_type = 'circle'
104 self._center = center
105 self._width = radius * 2
106 self._height = radius * 2
107 # circle drawn on axes transform
108 self.set_transform(self.axes.transAxes)
109 self.stale = True
110
111 def set_patch_line(self):
112 """Set the spine to be linear."""
113 self._patch_type = 'line'
114 self.stale = True
115
116 # Behavior copied from mpatches.Ellipse:
117 def _recompute_transform(self):
118 """
119 Notes
120 -----
121 This cannot be called until after this has been added to an Axes,
122 otherwise unit conversion will fail. This makes it very important to
123 call the accessor method and not directly access the transformation
124 member variable.
125 """
126 assert self._patch_type in ('arc', 'circle')
127 center = (self.convert_xunits(self._center[0]),
128 self.convert_yunits(self._center[1]))
129 width = self.convert_xunits(self._width)
130 height = self.convert_yunits(self._height)
131 self._patch_transform = mtransforms.Affine2D() \
132 .scale(width * 0.5, height * 0.5) \
133 .translate(*center)
134
135 def get_patch_transform(self):
136 if self._patch_type in ('arc', 'circle'):
137 self._recompute_transform()
138 return self._patch_transform
139 else:
140 return super().get_patch_transform()
141
142 def get_window_extent(self, renderer=None):
143 """
144 Return the window extent of the spines in display space, including
145 padding for ticks (but not their labels)
146
147 See Also
148 --------
149 matplotlib.axes.Axes.get_tightbbox
150 matplotlib.axes.Axes.get_window_extent
151 """
152 # make sure the location is updated so that transforms etc are correct:
153 self._adjust_location()
154 bb = super().get_window_extent(renderer=renderer)
155 if self.axis is None or not self.axis.get_visible():
156 return bb
157 bboxes = [bb]
158 drawn_ticks = self.axis._update_ticks()
159
160 major_tick = next(iter({*drawn_ticks} & {*self.axis.majorTicks}), None)
161 minor_tick = next(iter({*drawn_ticks} & {*self.axis.minorTicks}), None)
162 for tick in [major_tick, minor_tick]:
163 if tick is None:
164 continue
165 bb0 = bb.frozen()
166 tickl = tick._size
167 tickdir = tick._tickdir
168 if tickdir == 'out':
169 padout = 1
170 padin = 0
171 elif tickdir == 'in':
172 padout = 0
173 padin = 1
174 else:
175 padout = 0.5
176 padin = 0.5
177 padout = padout * tickl / 72 * self.figure.dpi
178 padin = padin * tickl / 72 * self.figure.dpi
179
180 if tick.tick1line.get_visible():
181 if self.spine_type == 'left':
182 bb0.x0 = bb0.x0 - padout
183 bb0.x1 = bb0.x1 + padin
184 elif self.spine_type == 'bottom':
185 bb0.y0 = bb0.y0 - padout
186 bb0.y1 = bb0.y1 + padin
187
188 if tick.tick2line.get_visible():
189 if self.spine_type == 'right':
190 bb0.x1 = bb0.x1 + padout
191 bb0.x0 = bb0.x0 - padin
192 elif self.spine_type == 'top':
193 bb0.y1 = bb0.y1 + padout
194 bb0.y0 = bb0.y0 - padout
195 bboxes.append(bb0)
196
197 return mtransforms.Bbox.union(bboxes)
198
199 def get_path(self):
200 return self._path
201
202 def _ensure_position_is_set(self):
203 if self._position is None:
204 # default position
205 self._position = ('outward', 0.0) # in points
206 self.set_position(self._position)
207
208 def register_axis(self, axis):
209 """
210 Register an axis.
211
212 An axis should be registered with its corresponding spine from
213 the Axes instance. This allows the spine to clear any axis
214 properties when needed.
215 """
216 self.axis = axis
217 self.stale = True
218
219 def clear(self):
220 """Clear the current spine."""
221 self._clear()
222 if self.axis is not None:
223 self.axis.clear()
224
225 def _clear(self):
226 """
227 Clear things directly related to the spine.
228
229 In this way it is possible to avoid clearing the Axis as well when calling
230 from library code where it is known that the Axis is cleared separately.
231 """
232 self._position = None # clear position
233
234 def _adjust_location(self):
235 """Automatically set spine bounds to the view interval."""
236
237 if self.spine_type == 'circle':
238 return
239
240 if self._bounds is not None:
241 low, high = self._bounds
242 elif self.spine_type in ('left', 'right'):
243 low, high = self.axes.viewLim.intervaly
244 elif self.spine_type in ('top', 'bottom'):
245 low, high = self.axes.viewLim.intervalx
246 else:
247 raise ValueError(f'unknown spine spine_type: {self.spine_type}')
248
249 if self._patch_type == 'arc':
250 if self.spine_type in ('bottom', 'top'):
251 try:
252 direction = self.axes.get_theta_direction()
253 except AttributeError:
254 direction = 1
255 try:
256 offset = self.axes.get_theta_offset()
257 except AttributeError:
258 offset = 0
259 low = low * direction + offset
260 high = high * direction + offset
261 if low > high:
262 low, high = high, low
263
264 self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high))
265
266 if self.spine_type == 'bottom':
267 rmin, rmax = self.axes.viewLim.intervaly
268 try:
269 rorigin = self.axes.get_rorigin()
270 except AttributeError:
271 rorigin = rmin
272 scaled_diameter = (rmin - rorigin) / (rmax - rorigin)
273 self._height = scaled_diameter
274 self._width = scaled_diameter
275
276 else:
277 raise ValueError('unable to set bounds for spine "%s"' %
278 self.spine_type)
279 else:
280 v1 = self._path.vertices
281 assert v1.shape == (2, 2), 'unexpected vertices shape'
282 if self.spine_type in ['left', 'right']:
283 v1[0, 1] = low
284 v1[1, 1] = high
285 elif self.spine_type in ['bottom', 'top']:
286 v1[0, 0] = low
287 v1[1, 0] = high
288 else:
289 raise ValueError('unable to set bounds for spine "%s"' %
290 self.spine_type)
291
292 @allow_rasterization
293 def draw(self, renderer):
294 self._adjust_location()
295 ret = super().draw(renderer)
296 self.stale = False
297 return ret
298
299 def set_position(self, position):
300 """
301 Set the position of the spine.
302
303 Spine position is specified by a 2 tuple of (position type,
304 amount). The position types are:
305
306 * 'outward': place the spine out from the data area by the specified
307 number of points. (Negative values place the spine inwards.)
308 * 'axes': place the spine at the specified Axes coordinate (0 to 1).
309 * 'data': place the spine at the specified data coordinate.
310
311 Additionally, shorthand notations define a special positions:
312
313 * 'center' -> ``('axes', 0.5)``
314 * 'zero' -> ``('data', 0.0)``
315
316 Examples
317 --------
318 :doc:`/gallery/spines/spine_placement_demo`
319 """
320 if position in ('center', 'zero'): # special positions
321 pass
322 else:
323 if len(position) != 2:
324 raise ValueError("position should be 'center' or 2-tuple")
325 if position[0] not in ['outward', 'axes', 'data']:
326 raise ValueError("position[0] should be one of 'outward', "
327 "'axes', or 'data' ")
328 self._position = position
329 self.set_transform(self.get_spine_transform())
330 if self.axis is not None:
331 self.axis.reset_ticks()
332 self.stale = True
333
334 def get_position(self):
335 """Return the spine position."""
336 self._ensure_position_is_set()
337 return self._position
338
339 def get_spine_transform(self):
340 """Return the spine transform."""
341 self._ensure_position_is_set()
342
343 position = self._position
344 if isinstance(position, str):
345 if position == 'center':
346 position = ('axes', 0.5)
347 elif position == 'zero':
348 position = ('data', 0)
349 assert len(position) == 2, 'position should be 2-tuple'
350 position_type, amount = position
351 _api.check_in_list(['axes', 'outward', 'data'],
352 position_type=position_type)
353 if self.spine_type in ['left', 'right']:
354 base_transform = self.axes.get_yaxis_transform(which='grid')
355 elif self.spine_type in ['top', 'bottom']:
356 base_transform = self.axes.get_xaxis_transform(which='grid')
357 else:
358 raise ValueError(f'unknown spine spine_type: {self.spine_type!r}')
359
360 if position_type == 'outward':
361 if amount == 0: # short circuit commonest case
362 return base_transform
363 else:
364 offset_vec = {'left': (-1, 0), 'right': (1, 0),
365 'bottom': (0, -1), 'top': (0, 1),
366 }[self.spine_type]
367 # calculate x and y offset in dots
368 offset_dots = amount * np.array(offset_vec) / 72
369 return (base_transform
370 + mtransforms.ScaledTranslation(
371 *offset_dots, self.figure.dpi_scale_trans))
372 elif position_type == 'axes':
373 if self.spine_type in ['left', 'right']:
374 # keep y unchanged, fix x at amount
375 return (mtransforms.Affine2D.from_values(0, 0, 0, 1, amount, 0)
376 + base_transform)
377 elif self.spine_type in ['bottom', 'top']:
378 # keep x unchanged, fix y at amount
379 return (mtransforms.Affine2D.from_values(1, 0, 0, 0, 0, amount)
380 + base_transform)
381 elif position_type == 'data':
382 if self.spine_type in ('right', 'top'):
383 # The right and top spines have a default position of 1 in
384 # axes coordinates. When specifying the position in data
385 # coordinates, we need to calculate the position relative to 0.
386 amount -= 1
387 if self.spine_type in ('left', 'right'):
388 return mtransforms.blended_transform_factory(
389 mtransforms.Affine2D().translate(amount, 0)
390 + self.axes.transData,
391 self.axes.transData)
392 elif self.spine_type in ('bottom', 'top'):
393 return mtransforms.blended_transform_factory(
394 self.axes.transData,
395 mtransforms.Affine2D().translate(0, amount)
396 + self.axes.transData)
397
398 def set_bounds(self, low=None, high=None):
399 """
400 Set the spine bounds.
401
402 Parameters
403 ----------
404 low : float or None, optional
405 The lower spine bound. Passing *None* leaves the limit unchanged.
406
407 The bounds may also be passed as the tuple (*low*, *high*) as the
408 first positional argument.
409
410 .. ACCEPTS: (low: float, high: float)
411
412 high : float or None, optional
413 The higher spine bound. Passing *None* leaves the limit unchanged.
414 """
415 if self.spine_type == 'circle':
416 raise ValueError(
417 'set_bounds() method incompatible with circular spines')
418 if high is None and np.iterable(low):
419 low, high = low
420 old_low, old_high = self.get_bounds() or (None, None)
421 if low is None:
422 low = old_low
423 if high is None:
424 high = old_high
425 self._bounds = (low, high)
426 self.stale = True
427
428 def get_bounds(self):
429 """Get the bounds of the spine."""
430 return self._bounds
431
432 @classmethod
433 def linear_spine(cls, axes, spine_type, **kwargs):
434 """Create and return a linear `Spine`."""
435 # all values of 0.999 get replaced upon call to set_bounds()
436 if spine_type == 'left':
437 path = mpath.Path([(0.0, 0.999), (0.0, 0.999)])
438 elif spine_type == 'right':
439 path = mpath.Path([(1.0, 0.999), (1.0, 0.999)])
440 elif spine_type == 'bottom':
441 path = mpath.Path([(0.999, 0.0), (0.999, 0.0)])
442 elif spine_type == 'top':
443 path = mpath.Path([(0.999, 1.0), (0.999, 1.0)])
444 else:
445 raise ValueError('unable to make path for spine "%s"' % spine_type)
446 result = cls(axes, spine_type, path, **kwargs)
447 result.set_visible(mpl.rcParams[f'axes.spines.{spine_type}'])
448
449 return result
450
451 @classmethod
452 def arc_spine(cls, axes, spine_type, center, radius, theta1, theta2,
453 **kwargs):
454 """Create and return an arc `Spine`."""
455 path = mpath.Path.arc(theta1, theta2)
456 result = cls(axes, spine_type, path, **kwargs)
457 result.set_patch_arc(center, radius, theta1, theta2)
458 return result
459
460 @classmethod
461 def circular_spine(cls, axes, center, radius, **kwargs):
462 """Create and return a circular `Spine`."""
463 path = mpath.Path.unit_circle()
464 spine_type = 'circle'
465 result = cls(axes, spine_type, path, **kwargs)
466 result.set_patch_circle(center, radius)
467 return result
468
469 def set_color(self, c):
470 """
471 Set the edgecolor.
472
473 Parameters
474 ----------
475 c : :mpltype:`color`
476
477 Notes
478 -----
479 This method does not modify the facecolor (which defaults to "none"),
480 unlike the `.Patch.set_color` method defined in the parent class. Use
481 `.Patch.set_facecolor` to set the facecolor.
482 """
483 self.set_edgecolor(c)
484 self.stale = True
485
486
487class SpinesProxy:
488 """
489 A proxy to broadcast ``set_*()`` and ``set()`` method calls to contained `.Spines`.
490
491 The proxy cannot be used for any other operations on its members.
492
493 The supported methods are determined dynamically based on the contained
494 spines. If not all spines support a given method, it's executed only on
495 the subset of spines that support it.
496 """
497 def __init__(self, spine_dict):
498 self._spine_dict = spine_dict
499
500 def __getattr__(self, name):
501 broadcast_targets = [spine for spine in self._spine_dict.values()
502 if hasattr(spine, name)]
503 if (name != 'set' and not name.startswith('set_')) or not broadcast_targets:
504 raise AttributeError(
505 f"'SpinesProxy' object has no attribute '{name}'")
506
507 def x(_targets, _funcname, *args, **kwargs):
508 for spine in _targets:
509 getattr(spine, _funcname)(*args, **kwargs)
510 x = functools.partial(x, broadcast_targets, name)
511 x.__doc__ = broadcast_targets[0].__doc__
512 return x
513
514 def __dir__(self):
515 names = []
516 for spine in self._spine_dict.values():
517 names.extend(name
518 for name in dir(spine) if name.startswith('set_'))
519 return list(sorted(set(names)))
520
521
522class Spines(MutableMapping):
523 r"""
524 The container of all `.Spine`\s in an Axes.
525
526 The interface is dict-like mapping names (e.g. 'left') to `.Spine` objects.
527 Additionally, it implements some pandas.Series-like features like accessing
528 elements by attribute::
529
530 spines['top'].set_visible(False)
531 spines.top.set_visible(False)
532
533 Multiple spines can be addressed simultaneously by passing a list::
534
535 spines[['top', 'right']].set_visible(False)
536
537 Use an open slice to address all spines::
538
539 spines[:].set_visible(False)
540
541 The latter two indexing methods will return a `SpinesProxy` that broadcasts all
542 ``set_*()`` and ``set()`` calls to its members, but cannot be used for any other
543 operation.
544 """
545 def __init__(self, **kwargs):
546 self._dict = kwargs
547
548 @classmethod
549 def from_dict(cls, d):
550 return cls(**d)
551
552 def __getstate__(self):
553 return self._dict
554
555 def __setstate__(self, state):
556 self.__init__(**state)
557
558 def __getattr__(self, name):
559 try:
560 return self._dict[name]
561 except KeyError:
562 raise AttributeError(
563 f"'Spines' object does not contain a '{name}' spine")
564
565 def __getitem__(self, key):
566 if isinstance(key, list):
567 unknown_keys = [k for k in key if k not in self._dict]
568 if unknown_keys:
569 raise KeyError(', '.join(unknown_keys))
570 return SpinesProxy({k: v for k, v in self._dict.items()
571 if k in key})
572 if isinstance(key, tuple):
573 raise ValueError('Multiple spines must be passed as a single list')
574 if isinstance(key, slice):
575 if key.start is None and key.stop is None and key.step is None:
576 return SpinesProxy(self._dict)
577 else:
578 raise ValueError(
579 'Spines does not support slicing except for the fully '
580 'open slice [:] to access all spines.')
581 return self._dict[key]
582
583 def __setitem__(self, key, value):
584 # TODO: Do we want to deprecate adding spines?
585 self._dict[key] = value
586
587 def __delitem__(self, key):
588 # TODO: Do we want to deprecate deleting spines?
589 del self._dict[key]
590
591 def __iter__(self):
592 return iter(self._dict)
593
594 def __len__(self):
595 return len(self._dict)