123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- import numpy as np
- import matplotlib
- from matplotlib import cbook, docstring, rcParams
- from matplotlib.artist import allow_rasterization
- import matplotlib.cbook as cbook
- import matplotlib.transforms as mtransforms
- import matplotlib.patches as mpatches
- import matplotlib.path as mpath
- class Spine(mpatches.Patch):
- """
- An axis spine -- the line noting the data area boundaries
- Spines are the lines connecting the axis tick marks and noting the
- boundaries of the data area. They can be placed at arbitrary
- positions. See function:`~matplotlib.spines.Spine.set_position`
- for more information.
- The default position is ``('outward',0)``.
- Spines are subclasses of class:`~matplotlib.patches.Patch`, and
- inherit much of their behavior.
- Spines draw a line, a circle, or an arc depending if
- function:`~matplotlib.spines.Spine.set_patch_line`,
- function:`~matplotlib.spines.Spine.set_patch_circle`, or
- function:`~matplotlib.spines.Spine.set_patch_arc` has been called.
- Line-like is the default.
- """
- def __str__(self):
- return "Spine"
- @docstring.dedent_interpd
- def __init__(self, axes, spine_type, path, **kwargs):
- """
- Parameters
- ----------
- axes : `~matplotlib.axes.Axes`
- The `~.axes.Axes` instance containing the spine.
- spine_type : str
- The spine type.
- path : `~matplotlib.path.Path`
- The `.Path` instance used to draw the spine.
- Other Parameters
- ----------------
- **kwargs
- Valid keyword arguments are:
- %(Patch)s
- """
- super().__init__(**kwargs)
- self.axes = axes
- self.set_figure(self.axes.figure)
- self.spine_type = spine_type
- self.set_facecolor('none')
- self.set_edgecolor(rcParams['axes.edgecolor'])
- self.set_linewidth(rcParams['axes.linewidth'])
- self.set_capstyle('projecting')
- self.axis = None
- self.set_zorder(2.5)
- self.set_transform(self.axes.transData) # default transform
- self._bounds = None # default bounds
- self._smart_bounds = False # deprecated in 3.2
- # Defer initial position determination. (Not much support for
- # non-rectangular axes is currently implemented, and this lets
- # them pass through the spines machinery without errors.)
- self._position = None
- cbook._check_isinstance(matplotlib.path.Path, path=path)
- self._path = path
- # To support drawing both linear and circular spines, this
- # class implements Patch behavior three ways. If
- # self._patch_type == 'line', behave like a mpatches.PathPatch
- # instance. If self._patch_type == 'circle', behave like a
- # mpatches.Ellipse instance. If self._patch_type == 'arc', behave like
- # a mpatches.Arc instance.
- self._patch_type = 'line'
- # Behavior copied from mpatches.Ellipse:
- # Note: This cannot be calculated until this is added to an Axes
- self._patch_transform = mtransforms.IdentityTransform()
- @cbook.deprecated("3.2")
- def set_smart_bounds(self, value):
- """Set the spine and associated axis to have smart bounds."""
- self._smart_bounds = value
- # also set the axis if possible
- if self.spine_type in ('left', 'right'):
- self.axes.yaxis.set_smart_bounds(value)
- elif self.spine_type in ('top', 'bottom'):
- self.axes.xaxis.set_smart_bounds(value)
- self.stale = True
- @cbook.deprecated("3.2")
- def get_smart_bounds(self):
- """Return whether the spine has smart bounds."""
- return self._smart_bounds
- def set_patch_arc(self, center, radius, theta1, theta2):
- """Set the spine to be arc-like."""
- self._patch_type = 'arc'
- self._center = center
- self._width = radius * 2
- self._height = radius * 2
- self._theta1 = theta1
- self._theta2 = theta2
- self._path = mpath.Path.arc(theta1, theta2)
- # arc drawn on axes transform
- self.set_transform(self.axes.transAxes)
- self.stale = True
- def set_patch_circle(self, center, radius):
- """Set the spine to be circular."""
- self._patch_type = 'circle'
- self._center = center
- self._width = radius * 2
- self._height = radius * 2
- # circle drawn on axes transform
- self.set_transform(self.axes.transAxes)
- self.stale = True
- def set_patch_line(self):
- """Set the spine to be linear."""
- self._patch_type = 'line'
- self.stale = True
- # Behavior copied from mpatches.Ellipse:
- def _recompute_transform(self):
- """
- Notes
- -----
- This cannot be called until after this has been added to an Axes,
- otherwise unit conversion will fail. This makes it very important to
- call the accessor method and not directly access the transformation
- member variable.
- """
- assert self._patch_type in ('arc', 'circle')
- center = (self.convert_xunits(self._center[0]),
- self.convert_yunits(self._center[1]))
- width = self.convert_xunits(self._width)
- height = self.convert_yunits(self._height)
- self._patch_transform = mtransforms.Affine2D() \
- .scale(width * 0.5, height * 0.5) \
- .translate(*center)
- def get_patch_transform(self):
- if self._patch_type in ('arc', 'circle'):
- self._recompute_transform()
- return self._patch_transform
- else:
- return super().get_patch_transform()
- def get_window_extent(self, renderer=None):
- """
- Return the window extent of the spines in display space, including
- padding for ticks (but not their labels)
- See Also
- --------
- matplotlib.axes.Axes.get_tightbbox
- matplotlib.axes.Axes.get_window_extent
- """
- # make sure the location is updated so that transforms etc are correct:
- self._adjust_location()
- bb = super().get_window_extent(renderer=renderer)
- if self.axis is None:
- return bb
- bboxes = [bb]
- tickstocheck = [self.axis.majorTicks[0]]
- if len(self.axis.minorTicks) > 1:
- # only pad for minor ticks if there are more than one
- # of them. There is always one...
- tickstocheck.append(self.axis.minorTicks[1])
- for tick in tickstocheck:
- bb0 = bb.frozen()
- tickl = tick._size
- tickdir = tick._tickdir
- if tickdir == 'out':
- padout = 1
- padin = 0
- elif tickdir == 'in':
- padout = 0
- padin = 1
- else:
- padout = 0.5
- padin = 0.5
- padout = padout * tickl / 72 * self.figure.dpi
- padin = padin * tickl / 72 * self.figure.dpi
- if tick.tick1line.get_visible():
- if self.spine_type == 'left':
- bb0.x0 = bb0.x0 - padout
- bb0.x1 = bb0.x1 + padin
- elif self.spine_type == 'bottom':
- bb0.y0 = bb0.y0 - padout
- bb0.y1 = bb0.y1 + padin
- if tick.tick2line.get_visible():
- if self.spine_type == 'right':
- bb0.x1 = bb0.x1 + padout
- bb0.x0 = bb0.x0 - padin
- elif self.spine_type == 'top':
- bb0.y1 = bb0.y1 + padout
- bb0.y0 = bb0.y0 - padout
- bboxes.append(bb0)
- return mtransforms.Bbox.union(bboxes)
- def get_path(self):
- return self._path
- def _ensure_position_is_set(self):
- if self._position is None:
- # default position
- self._position = ('outward', 0.0) # in points
- self.set_position(self._position)
- def register_axis(self, axis):
- """Register an axis.
- An axis should be registered with its corresponding spine from
- the Axes instance. This allows the spine to clear any axis
- properties when needed.
- """
- self.axis = axis
- if self.axis is not None:
- self.axis.cla()
- self.stale = True
- def cla(self):
- """Clear the current spine."""
- self._position = None # clear position
- if self.axis is not None:
- self.axis.cla()
- @cbook.deprecated("3.1")
- def is_frame_like(self):
- """Return True if directly on axes frame.
- This is useful for determining if a spine is the edge of an
- old style MPL plot. If so, this function will return True.
- """
- self._ensure_position_is_set()
- position = self._position
- if isinstance(position, str):
- if position == 'center':
- position = ('axes', 0.5)
- elif position == 'zero':
- position = ('data', 0)
- if len(position) != 2:
- raise ValueError("position should be 2-tuple")
- position_type, amount = position
- if position_type == 'outward' and amount == 0:
- return True
- else:
- return False
- def _adjust_location(self):
- """Automatically set spine bounds to the view interval."""
- if self.spine_type == 'circle':
- return
- if self._bounds is None:
- if self.spine_type in ('left', 'right'):
- low, high = self.axes.viewLim.intervaly
- elif self.spine_type in ('top', 'bottom'):
- low, high = self.axes.viewLim.intervalx
- else:
- raise ValueError('unknown spine spine_type: %s' %
- self.spine_type)
- if self._smart_bounds: # deprecated in 3.2
- # attempt to set bounds in sophisticated way
- # handle inverted limits
- viewlim_low, viewlim_high = sorted([low, high])
- if self.spine_type in ('left', 'right'):
- datalim_low, datalim_high = self.axes.dataLim.intervaly
- ticks = self.axes.get_yticks()
- elif self.spine_type in ('top', 'bottom'):
- datalim_low, datalim_high = self.axes.dataLim.intervalx
- ticks = self.axes.get_xticks()
- # handle inverted limits
- ticks = np.sort(ticks)
- datalim_low, datalim_high = sorted([datalim_low, datalim_high])
- if datalim_low < viewlim_low:
- # Data extends past view. Clip line to view.
- low = viewlim_low
- else:
- # Data ends before view ends.
- cond = (ticks <= datalim_low) & (ticks >= viewlim_low)
- tickvals = ticks[cond]
- if len(tickvals):
- # A tick is less than or equal to lowest data point.
- low = tickvals[-1]
- else:
- # No tick is available
- low = datalim_low
- low = max(low, viewlim_low)
- if datalim_high > viewlim_high:
- # Data extends past view. Clip line to view.
- high = viewlim_high
- else:
- # Data ends before view ends.
- cond = (ticks >= datalim_high) & (ticks <= viewlim_high)
- tickvals = ticks[cond]
- if len(tickvals):
- # A tick is greater than or equal to highest data
- # point.
- high = tickvals[0]
- else:
- # No tick is available
- high = datalim_high
- high = min(high, viewlim_high)
- else:
- low, high = self._bounds
- if self._patch_type == 'arc':
- if self.spine_type in ('bottom', 'top'):
- try:
- direction = self.axes.get_theta_direction()
- except AttributeError:
- direction = 1
- try:
- offset = self.axes.get_theta_offset()
- except AttributeError:
- offset = 0
- low = low * direction + offset
- high = high * direction + offset
- if low > high:
- low, high = high, low
- self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high))
- if self.spine_type == 'bottom':
- rmin, rmax = self.axes.viewLim.intervaly
- try:
- rorigin = self.axes.get_rorigin()
- except AttributeError:
- rorigin = rmin
- scaled_diameter = (rmin - rorigin) / (rmax - rorigin)
- self._height = scaled_diameter
- self._width = scaled_diameter
- else:
- raise ValueError('unable to set bounds for spine "%s"' %
- self.spine_type)
- else:
- v1 = self._path.vertices
- assert v1.shape == (2, 2), 'unexpected vertices shape'
- if self.spine_type in ['left', 'right']:
- v1[0, 1] = low
- v1[1, 1] = high
- elif self.spine_type in ['bottom', 'top']:
- v1[0, 0] = low
- v1[1, 0] = high
- else:
- raise ValueError('unable to set bounds for spine "%s"' %
- self.spine_type)
- @allow_rasterization
- def draw(self, renderer):
- self._adjust_location()
- ret = super().draw(renderer)
- self.stale = False
- return ret
- def set_position(self, position):
- """Set the position of the spine.
- Spine position is specified by a 2 tuple of (position type,
- amount). The position types are:
- * 'outward' : place the spine out from the data area by the
- specified number of points. (Negative values specify placing the
- spine inward.)
- * 'axes' : place the spine at the specified Axes coordinate (from
- 0.0-1.0).
- * 'data' : place the spine at the specified data coordinate.
- Additionally, shorthand notations define a special positions:
- * 'center' -> ('axes',0.5)
- * 'zero' -> ('data', 0.0)
- """
- if position in ('center', 'zero'):
- # special positions
- pass
- else:
- if len(position) != 2:
- raise ValueError("position should be 'center' or 2-tuple")
- if position[0] not in ['outward', 'axes', 'data']:
- raise ValueError("position[0] should be one of 'outward', "
- "'axes', or 'data' ")
- self._position = position
- self.set_transform(self.get_spine_transform())
- if self.axis is not None:
- self.axis.reset_ticks()
- self.stale = True
- def get_position(self):
- """Return the spine position."""
- self._ensure_position_is_set()
- return self._position
- def get_spine_transform(self):
- """Return the spine transform."""
- self._ensure_position_is_set()
- position = self._position
- if isinstance(position, str):
- if position == 'center':
- position = ('axes', 0.5)
- elif position == 'zero':
- position = ('data', 0)
- assert len(position) == 2, 'position should be 2-tuple'
- position_type, amount = position
- cbook._check_in_list(['axes', 'outward', 'data'],
- position_type=position_type)
- if self.spine_type in ['left', 'right']:
- base_transform = self.axes.get_yaxis_transform(which='grid')
- elif self.spine_type in ['top', 'bottom']:
- base_transform = self.axes.get_xaxis_transform(which='grid')
- else:
- raise ValueError(f'unknown spine spine_type: {self.spine_type!r}')
- if position_type == 'outward':
- if amount == 0: # short circuit commonest case
- return base_transform
- else:
- offset_vec = {'left': (-1, 0), 'right': (1, 0),
- 'bottom': (0, -1), 'top': (0, 1),
- }[self.spine_type]
- # calculate x and y offset in dots
- offset_dots = amount * np.array(offset_vec) / 72
- return (base_transform
- + mtransforms.ScaledTranslation(
- *offset_dots, self.figure.dpi_scale_trans))
- elif position_type == 'axes':
- if self.spine_type in ['left', 'right']:
- # keep y unchanged, fix x at amount
- return (mtransforms.Affine2D.from_values(0, 0, 0, 1, amount, 0)
- + base_transform)
- elif self.spine_type in ['bottom', 'top']:
- # keep x unchanged, fix y at amount
- return (mtransforms.Affine2D.from_values(1, 0, 0, 0, 0, amount)
- + base_transform)
- elif position_type == 'data':
- if self.spine_type in ('right', 'top'):
- # The right and top spines have a default position of 1 in
- # axes coordinates. When specifying the position in data
- # coordinates, we need to calculate the position relative to 0.
- amount -= 1
- if self.spine_type in ('left', 'right'):
- return mtransforms.blended_transform_factory(
- mtransforms.Affine2D().translate(amount, 0)
- + self.axes.transData,
- self.axes.transData)
- elif self.spine_type in ('bottom', 'top'):
- return mtransforms.blended_transform_factory(
- self.axes.transData,
- mtransforms.Affine2D().translate(0, amount)
- + self.axes.transData)
- def set_bounds(self, low=None, high=None):
- """
- Set the spine bounds.
- Parameters
- ----------
- low : float or None, optional
- The lower spine bound. Passing *None* leaves the limit unchanged.
- The bounds may also be passed as the tuple (*low*, *high*) as the
- first positional argument.
- .. ACCEPTS: (low: float, high: float)
- high : float or None, optional
- The higher spine bound. Passing *None* leaves the limit unchanged.
- """
- if self.spine_type == 'circle':
- raise ValueError(
- 'set_bounds() method incompatible with circular spines')
- if high is None and np.iterable(low):
- low, high = low
- old_low, old_high = self.get_bounds() or (None, None)
- if low is None:
- low = old_low
- if high is None:
- high = old_high
- self._bounds = (low, high)
- self.stale = True
- def get_bounds(self):
- """Get the bounds of the spine."""
- return self._bounds
- @classmethod
- def linear_spine(cls, axes, spine_type, **kwargs):
- """
- Returns a linear `Spine`.
- """
- # all values of 0.999 get replaced upon call to set_bounds()
- if spine_type == 'left':
- path = mpath.Path([(0.0, 0.999), (0.0, 0.999)])
- elif spine_type == 'right':
- path = mpath.Path([(1.0, 0.999), (1.0, 0.999)])
- elif spine_type == 'bottom':
- path = mpath.Path([(0.999, 0.0), (0.999, 0.0)])
- elif spine_type == 'top':
- path = mpath.Path([(0.999, 1.0), (0.999, 1.0)])
- else:
- raise ValueError('unable to make path for spine "%s"' % spine_type)
- result = cls(axes, spine_type, path, **kwargs)
- result.set_visible(rcParams['axes.spines.{0}'.format(spine_type)])
- return result
- @classmethod
- def arc_spine(cls, axes, spine_type, center, radius, theta1, theta2,
- **kwargs):
- """
- Returns an arc `Spine`.
- """
- path = mpath.Path.arc(theta1, theta2)
- result = cls(axes, spine_type, path, **kwargs)
- result.set_patch_arc(center, radius, theta1, theta2)
- return result
- @classmethod
- def circular_spine(cls, axes, center, radius, **kwargs):
- """
- Returns a circular `Spine`.
- """
- path = mpath.Path.unit_circle()
- spine_type = 'circle'
- result = cls(axes, spine_type, path, **kwargs)
- result.set_patch_circle(center, radius)
- return result
- def set_color(self, c):
- """
- Set the edgecolor.
- Parameters
- ----------
- c : color
- Notes
- -----
- This method does not modify the facecolor (which defaults to "none"),
- unlike the `Patch.set_color` method defined in the parent class. Use
- `Patch.set_facecolor` to set the facecolor.
- """
- self.set_edgecolor(c)
- self.stale = True
|