import warnings
|
|
|
|
import numpy as np
|
|
|
|
import matplotlib
|
|
from matplotlib import docstring, rcParams
|
|
from matplotlib.artist import allow_rasterization
|
|
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):
|
|
"""
|
|
- *axes* : the Axes instance containing the spine
|
|
- *spine_type* : a string specifying the spine type
|
|
- *path* : the path instance used to draw the spine
|
|
|
|
Valid kwargs 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
|
|
|
|
# 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
|
|
if not isinstance(path, matplotlib.path.Path):
|
|
raise ValueError(
|
|
"'path' must be an instance of 'matplotlib.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()
|
|
|
|
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
|
|
|
|
def get_smart_bounds(self):
|
|
"""get 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):
|
|
"""NOTE: 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):
|
|
# make sure the location is updated so that transforms etc are
|
|
# correct:
|
|
self._adjust_location()
|
|
return super().get_window_extent(renderer=renderer)
|
|
|
|
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()
|
|
|
|
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:
|
|
# 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 _calc_offset_transform(self):
|
|
"""calculate the offset transform performed by the spine"""
|
|
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
|
|
assert position_type in ('axes', 'outward', 'data')
|
|
if position_type == 'outward':
|
|
if amount == 0:
|
|
# short circuit commonest case
|
|
self._spine_transform = ('identity',
|
|
mtransforms.IdentityTransform())
|
|
elif self.spine_type in ['left', 'right', 'top', 'bottom']:
|
|
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_x = amount * offset_vec[0] / 72.0
|
|
offset_y = amount * offset_vec[1] / 72.0
|
|
self._spine_transform = ('post',
|
|
mtransforms.ScaledTranslation(
|
|
offset_x,
|
|
offset_y,
|
|
self.figure.dpi_scale_trans))
|
|
else:
|
|
warnings.warn('unknown spine type "%s": no spine '
|
|
'offset performed' % self.spine_type)
|
|
self._spine_transform = ('identity',
|
|
mtransforms.IdentityTransform())
|
|
elif position_type == 'axes':
|
|
if self.spine_type in ('left', 'right'):
|
|
self._spine_transform = ('pre',
|
|
mtransforms.Affine2D.from_values(
|
|
# keep y unchanged, fix x at
|
|
# amount
|
|
0, 0, 0, 1, amount, 0))
|
|
elif self.spine_type in ('bottom', 'top'):
|
|
self._spine_transform = ('pre',
|
|
mtransforms.Affine2D.from_values(
|
|
# keep x unchanged, fix y at
|
|
# amount
|
|
1, 0, 0, 0, 0, amount))
|
|
else:
|
|
warnings.warn('unknown spine type "%s": no spine '
|
|
'offset performed' % self.spine_type)
|
|
self._spine_transform = ('identity',
|
|
mtransforms.IdentityTransform())
|
|
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'):
|
|
self._spine_transform = ('data',
|
|
mtransforms.Affine2D().translate(
|
|
amount, 0))
|
|
elif self.spine_type in ('bottom', 'top'):
|
|
self._spine_transform = ('data',
|
|
mtransforms.Affine2D().translate(
|
|
0, amount))
|
|
else:
|
|
warnings.warn('unknown spine type "%s": no spine '
|
|
'offset performed' % self.spine_type)
|
|
self._spine_transform = ('identity',
|
|
mtransforms.IdentityTransform())
|
|
|
|
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._calc_offset_transform()
|
|
|
|
self.set_transform(self.get_spine_transform())
|
|
|
|
if self.axis is not None:
|
|
self.axis.reset_ticks()
|
|
self.stale = True
|
|
|
|
def get_position(self):
|
|
"""get the spine position"""
|
|
self._ensure_position_is_set()
|
|
return self._position
|
|
|
|
def get_spine_transform(self):
|
|
"""get the spine transform"""
|
|
self._ensure_position_is_set()
|
|
what, how = self._spine_transform
|
|
|
|
if what == 'data':
|
|
# special case data based spine locations
|
|
data_xform = self.axes.transScale + \
|
|
(how + self.axes.transLimits + self.axes.transAxes)
|
|
if self.spine_type in ['left', 'right']:
|
|
result = mtransforms.blended_transform_factory(
|
|
data_xform, self.axes.transData)
|
|
elif self.spine_type in ['top', 'bottom']:
|
|
result = mtransforms.blended_transform_factory(
|
|
self.axes.transData, data_xform)
|
|
else:
|
|
raise ValueError('unknown spine spine_type: %s' %
|
|
self.spine_type)
|
|
return result
|
|
|
|
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('unknown spine spine_type: %s' %
|
|
self.spine_type)
|
|
|
|
if what == 'identity':
|
|
return base_transform
|
|
elif what == 'post':
|
|
return base_transform + how
|
|
elif what == 'pre':
|
|
return how + base_transform
|
|
else:
|
|
raise ValueError("unknown spine_transform type: %s" % what)
|
|
|
|
def set_bounds(self, low, high):
|
|
"""Set the bounds of the spine."""
|
|
if self.spine_type == 'circle':
|
|
raise ValueError(
|
|
'set_bounds() method incompatible with circular spines')
|
|
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):
|
|
"""
|
|
(staticmethod) Returns a linear :class:`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):
|
|
"""
|
|
(classmethod) Returns an arc :class:`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):
|
|
"""
|
|
(staticmethod) Returns a circular :class:`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 or sequence of rgba tuples
|
|
|
|
.. seealso::
|
|
|
|
:meth:`set_facecolor`, :meth:`set_edgecolor`
|
|
For setting the edge or face color individually.
|
|
"""
|
|
# The facecolor of a spine is always 'none' by default -- let
|
|
# the user change it manually if desired.
|
|
self.set_edgecolor(c)
|
|
self.stale = True
|