Coverage for /usr/lib/python3/dist-packages/matplotlib/spines.py: 58%
313 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1from collections.abc import MutableMapping
2import functools
4import numpy as np
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
14class Spine(mpatches.Patch):
15 """
16 An axis spine -- the line noting the data area boundaries.
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.
22 The default position is ``('outward', 0)``.
24 Spines are subclasses of `.Patch`, and inherit much of their behavior.
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.
30 For examples see :ref:`spines_examples`.
31 """
32 def __str__(self):
33 return "Spine"
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.
47 Other Parameters
48 ----------------
49 **kwargs
50 Valid keyword arguments are:
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
64 self.set_zorder(2.5)
65 self.set_transform(self.axes.transData) # default transform
67 self._bounds = None # default bounds
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
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'
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()
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
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
111 def set_patch_line(self):
112 """Set the spine to be linear."""
113 self._patch_type = 'line'
114 self.stale = True
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)
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()
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)
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:
156 return bb
157 bboxes = [bb]
158 tickstocheck = [self.axis.majorTicks[0]]
159 if len(self.axis.minorTicks) > 1:
160 # only pad for minor ticks if there are more than one
161 # of them. There is always one...
162 tickstocheck.append(self.axis.minorTicks[1])
163 for tick in tickstocheck:
164 bb0 = bb.frozen()
165 tickl = tick._size
166 tickdir = tick._tickdir
167 if tickdir == 'out':
168 padout = 1
169 padin = 0
170 elif tickdir == 'in':
171 padout = 0
172 padin = 1
173 else:
174 padout = 0.5
175 padin = 0.5
176 padout = padout * tickl / 72 * self.figure.dpi
177 padin = padin * tickl / 72 * self.figure.dpi
179 if tick.tick1line.get_visible():
180 if self.spine_type == 'left':
181 bb0.x0 = bb0.x0 - padout
182 bb0.x1 = bb0.x1 + padin
183 elif self.spine_type == 'bottom':
184 bb0.y0 = bb0.y0 - padout
185 bb0.y1 = bb0.y1 + padin
187 if tick.tick2line.get_visible():
188 if self.spine_type == 'right':
189 bb0.x1 = bb0.x1 + padout
190 bb0.x0 = bb0.x0 - padin
191 elif self.spine_type == 'top':
192 bb0.y1 = bb0.y1 + padout
193 bb0.y0 = bb0.y0 - padout
194 bboxes.append(bb0)
196 return mtransforms.Bbox.union(bboxes)
198 def get_path(self):
199 return self._path
201 def _ensure_position_is_set(self):
202 if self._position is None:
203 # default position
204 self._position = ('outward', 0.0) # in points
205 self.set_position(self._position)
207 def register_axis(self, axis):
208 """
209 Register an axis.
211 An axis should be registered with its corresponding spine from
212 the Axes instance. This allows the spine to clear any axis
213 properties when needed.
214 """
215 self.axis = axis
216 if self.axis is not None:
217 self.axis.clear()
218 self.stale = True
220 def clear(self):
221 """Clear the current spine."""
222 self._position = None # clear position
223 if self.axis is not None:
224 self.axis.clear()
226 def _adjust_location(self):
227 """Automatically set spine bounds to the view interval."""
229 if self.spine_type == 'circle':
230 return
232 if self._bounds is not None:
233 low, high = self._bounds
234 elif self.spine_type in ('left', 'right'):
235 low, high = self.axes.viewLim.intervaly
236 elif self.spine_type in ('top', 'bottom'):
237 low, high = self.axes.viewLim.intervalx
238 else:
239 raise ValueError(f'unknown spine spine_type: {self.spine_type}')
241 if self._patch_type == 'arc':
242 if self.spine_type in ('bottom', 'top'):
243 try:
244 direction = self.axes.get_theta_direction()
245 except AttributeError:
246 direction = 1
247 try:
248 offset = self.axes.get_theta_offset()
249 except AttributeError:
250 offset = 0
251 low = low * direction + offset
252 high = high * direction + offset
253 if low > high:
254 low, high = high, low
256 self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high))
258 if self.spine_type == 'bottom':
259 rmin, rmax = self.axes.viewLim.intervaly
260 try:
261 rorigin = self.axes.get_rorigin()
262 except AttributeError:
263 rorigin = rmin
264 scaled_diameter = (rmin - rorigin) / (rmax - rorigin)
265 self._height = scaled_diameter
266 self._width = scaled_diameter
268 else:
269 raise ValueError('unable to set bounds for spine "%s"' %
270 self.spine_type)
271 else:
272 v1 = self._path.vertices
273 assert v1.shape == (2, 2), 'unexpected vertices shape'
274 if self.spine_type in ['left', 'right']:
275 v1[0, 1] = low
276 v1[1, 1] = high
277 elif self.spine_type in ['bottom', 'top']:
278 v1[0, 0] = low
279 v1[1, 0] = high
280 else:
281 raise ValueError('unable to set bounds for spine "%s"' %
282 self.spine_type)
284 @allow_rasterization
285 def draw(self, renderer):
286 self._adjust_location()
287 ret = super().draw(renderer)
288 self.stale = False
289 return ret
291 def set_position(self, position):
292 """
293 Set the position of the spine.
295 Spine position is specified by a 2 tuple of (position type,
296 amount). The position types are:
298 * 'outward': place the spine out from the data area by the specified
299 number of points. (Negative values place the spine inwards.)
300 * 'axes': place the spine at the specified Axes coordinate (0 to 1).
301 * 'data': place the spine at the specified data coordinate.
303 Additionally, shorthand notations define a special positions:
305 * 'center' -> ``('axes', 0.5)``
306 * 'zero' -> ``('data', 0.0)``
308 Examples
309 --------
310 :doc:`/gallery/spines/spine_placement_demo`
311 """
312 if position in ('center', 'zero'): # special positions
313 pass
314 else:
315 if len(position) != 2:
316 raise ValueError("position should be 'center' or 2-tuple")
317 if position[0] not in ['outward', 'axes', 'data']:
318 raise ValueError("position[0] should be one of 'outward', "
319 "'axes', or 'data' ")
320 self._position = position
321 self.set_transform(self.get_spine_transform())
322 if self.axis is not None:
323 self.axis.reset_ticks()
324 self.stale = True
326 def get_position(self):
327 """Return the spine position."""
328 self._ensure_position_is_set()
329 return self._position
331 def get_spine_transform(self):
332 """Return the spine transform."""
333 self._ensure_position_is_set()
335 position = self._position
336 if isinstance(position, str):
337 if position == 'center':
338 position = ('axes', 0.5)
339 elif position == 'zero':
340 position = ('data', 0)
341 assert len(position) == 2, 'position should be 2-tuple'
342 position_type, amount = position
343 _api.check_in_list(['axes', 'outward', 'data'],
344 position_type=position_type)
345 if self.spine_type in ['left', 'right']:
346 base_transform = self.axes.get_yaxis_transform(which='grid')
347 elif self.spine_type in ['top', 'bottom']:
348 base_transform = self.axes.get_xaxis_transform(which='grid')
349 else:
350 raise ValueError(f'unknown spine spine_type: {self.spine_type!r}')
352 if position_type == 'outward':
353 if amount == 0: # short circuit commonest case
354 return base_transform
355 else:
356 offset_vec = {'left': (-1, 0), 'right': (1, 0),
357 'bottom': (0, -1), 'top': (0, 1),
358 }[self.spine_type]
359 # calculate x and y offset in dots
360 offset_dots = amount * np.array(offset_vec) / 72
361 return (base_transform
362 + mtransforms.ScaledTranslation(
363 *offset_dots, self.figure.dpi_scale_trans))
364 elif position_type == 'axes':
365 if self.spine_type in ['left', 'right']:
366 # keep y unchanged, fix x at amount
367 return (mtransforms.Affine2D.from_values(0, 0, 0, 1, amount, 0)
368 + base_transform)
369 elif self.spine_type in ['bottom', 'top']:
370 # keep x unchanged, fix y at amount
371 return (mtransforms.Affine2D.from_values(1, 0, 0, 0, 0, amount)
372 + base_transform)
373 elif position_type == 'data':
374 if self.spine_type in ('right', 'top'):
375 # The right and top spines have a default position of 1 in
376 # axes coordinates. When specifying the position in data
377 # coordinates, we need to calculate the position relative to 0.
378 amount -= 1
379 if self.spine_type in ('left', 'right'):
380 return mtransforms.blended_transform_factory(
381 mtransforms.Affine2D().translate(amount, 0)
382 + self.axes.transData,
383 self.axes.transData)
384 elif self.spine_type in ('bottom', 'top'):
385 return mtransforms.blended_transform_factory(
386 self.axes.transData,
387 mtransforms.Affine2D().translate(0, amount)
388 + self.axes.transData)
390 def set_bounds(self, low=None, high=None):
391 """
392 Set the spine bounds.
394 Parameters
395 ----------
396 low : float or None, optional
397 The lower spine bound. Passing *None* leaves the limit unchanged.
399 The bounds may also be passed as the tuple (*low*, *high*) as the
400 first positional argument.
402 .. ACCEPTS: (low: float, high: float)
404 high : float or None, optional
405 The higher spine bound. Passing *None* leaves the limit unchanged.
406 """
407 if self.spine_type == 'circle':
408 raise ValueError(
409 'set_bounds() method incompatible with circular spines')
410 if high is None and np.iterable(low):
411 low, high = low
412 old_low, old_high = self.get_bounds() or (None, None)
413 if low is None:
414 low = old_low
415 if high is None:
416 high = old_high
417 self._bounds = (low, high)
418 self.stale = True
420 def get_bounds(self):
421 """Get the bounds of the spine."""
422 return self._bounds
424 @classmethod
425 def linear_spine(cls, axes, spine_type, **kwargs):
426 """Create and return a linear `Spine`."""
427 # all values of 0.999 get replaced upon call to set_bounds()
428 if spine_type == 'left':
429 path = mpath.Path([(0.0, 0.999), (0.0, 0.999)])
430 elif spine_type == 'right':
431 path = mpath.Path([(1.0, 0.999), (1.0, 0.999)])
432 elif spine_type == 'bottom':
433 path = mpath.Path([(0.999, 0.0), (0.999, 0.0)])
434 elif spine_type == 'top':
435 path = mpath.Path([(0.999, 1.0), (0.999, 1.0)])
436 else:
437 raise ValueError('unable to make path for spine "%s"' % spine_type)
438 result = cls(axes, spine_type, path, **kwargs)
439 result.set_visible(mpl.rcParams['axes.spines.{0}'.format(spine_type)])
441 return result
443 @classmethod
444 def arc_spine(cls, axes, spine_type, center, radius, theta1, theta2,
445 **kwargs):
446 """Create and return an arc `Spine`."""
447 path = mpath.Path.arc(theta1, theta2)
448 result = cls(axes, spine_type, path, **kwargs)
449 result.set_patch_arc(center, radius, theta1, theta2)
450 return result
452 @classmethod
453 def circular_spine(cls, axes, center, radius, **kwargs):
454 """Create and return a circular `Spine`."""
455 path = mpath.Path.unit_circle()
456 spine_type = 'circle'
457 result = cls(axes, spine_type, path, **kwargs)
458 result.set_patch_circle(center, radius)
459 return result
461 def set_color(self, c):
462 """
463 Set the edgecolor.
465 Parameters
466 ----------
467 c : color
469 Notes
470 -----
471 This method does not modify the facecolor (which defaults to "none"),
472 unlike the `.Patch.set_color` method defined in the parent class. Use
473 `.Patch.set_facecolor` to set the facecolor.
474 """
475 self.set_edgecolor(c)
476 self.stale = True
479class SpinesProxy:
480 """
481 A proxy to broadcast ``set_*`` method calls to all contained `.Spines`.
483 The proxy cannot be used for any other operations on its members.
485 The supported methods are determined dynamically based on the contained
486 spines. If not all spines support a given method, it's executed only on
487 the subset of spines that support it.
488 """
489 def __init__(self, spine_dict):
490 self._spine_dict = spine_dict
492 def __getattr__(self, name):
493 broadcast_targets = [spine for spine in self._spine_dict.values()
494 if hasattr(spine, name)]
495 if not name.startswith('set_') or not broadcast_targets:
496 raise AttributeError(
497 f"'SpinesProxy' object has no attribute '{name}'")
499 def x(_targets, _funcname, *args, **kwargs):
500 for spine in _targets:
501 getattr(spine, _funcname)(*args, **kwargs)
502 x = functools.partial(x, broadcast_targets, name)
503 x.__doc__ = broadcast_targets[0].__doc__
504 return x
506 def __dir__(self):
507 names = []
508 for spine in self._spine_dict.values():
509 names.extend(name
510 for name in dir(spine) if name.startswith('set_'))
511 return list(sorted(set(names)))
514class Spines(MutableMapping):
515 r"""
516 The container of all `.Spine`\s in an Axes.
518 The interface is dict-like mapping names (e.g. 'left') to `.Spine` objects.
519 Additionally, it implements some pandas.Series-like features like accessing
520 elements by attribute::
522 spines['top'].set_visible(False)
523 spines.top.set_visible(False)
525 Multiple spines can be addressed simultaneously by passing a list::
527 spines[['top', 'right']].set_visible(False)
529 Use an open slice to address all spines::
531 spines[:].set_visible(False)
533 The latter two indexing methods will return a `SpinesProxy` that broadcasts
534 all ``set_*`` calls to its members, but cannot be used for any other
535 operation.
536 """
537 def __init__(self, **kwargs):
538 self._dict = kwargs
540 @classmethod
541 def from_dict(cls, d):
542 return cls(**d)
544 def __getstate__(self):
545 return self._dict
547 def __setstate__(self, state):
548 self.__init__(**state)
550 def __getattr__(self, name):
551 try:
552 return self._dict[name]
553 except KeyError:
554 raise AttributeError(
555 f"'Spines' object does not contain a '{name}' spine")
557 def __getitem__(self, key):
558 if isinstance(key, list):
559 unknown_keys = [k for k in key if k not in self._dict]
560 if unknown_keys:
561 raise KeyError(', '.join(unknown_keys))
562 return SpinesProxy({k: v for k, v in self._dict.items()
563 if k in key})
564 if isinstance(key, tuple):
565 raise ValueError('Multiple spines must be passed as a single list')
566 if isinstance(key, slice):
567 if key.start is None and key.stop is None and key.step is None:
568 return SpinesProxy(self._dict)
569 else:
570 raise ValueError(
571 'Spines does not support slicing except for the fully '
572 'open slice [:] to access all spines.')
573 return self._dict[key]
575 def __setitem__(self, key, value):
576 # TODO: Do we want to deprecate adding spines?
577 self._dict[key] = value
579 def __delitem__(self, key):
580 # TODO: Do we want to deprecate deleting spines?
581 del self._dict[key]
583 def __iter__(self):
584 return iter(self._dict)
586 def __len__(self):
587 return len(self._dict)