You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

544 lines
21 KiB

4 years ago
  1. import warnings
  2. import numpy as np
  3. import matplotlib
  4. from matplotlib import docstring, rcParams
  5. from matplotlib.artist import allow_rasterization
  6. import matplotlib.transforms as mtransforms
  7. import matplotlib.patches as mpatches
  8. import matplotlib.path as mpath
  9. class Spine(mpatches.Patch):
  10. """an axis spine -- the line noting the data area boundaries
  11. Spines are the lines connecting the axis tick marks and noting the
  12. boundaries of the data area. They can be placed at arbitrary
  13. positions. See function:`~matplotlib.spines.Spine.set_position`
  14. for more information.
  15. The default position is ``('outward',0)``.
  16. Spines are subclasses of class:`~matplotlib.patches.Patch`, and
  17. inherit much of their behavior.
  18. Spines draw a line, a circle, or an arc depending if
  19. function:`~matplotlib.spines.Spine.set_patch_line`,
  20. function:`~matplotlib.spines.Spine.set_patch_circle`, or
  21. function:`~matplotlib.spines.Spine.set_patch_arc` has been called.
  22. Line-like is the default.
  23. """
  24. def __str__(self):
  25. return "Spine"
  26. @docstring.dedent_interpd
  27. def __init__(self, axes, spine_type, path, **kwargs):
  28. """
  29. - *axes* : the Axes instance containing the spine
  30. - *spine_type* : a string specifying the spine type
  31. - *path* : the path instance used to draw the spine
  32. Valid kwargs are:
  33. %(Patch)s
  34. """
  35. super().__init__(**kwargs)
  36. self.axes = axes
  37. self.set_figure(self.axes.figure)
  38. self.spine_type = spine_type
  39. self.set_facecolor('none')
  40. self.set_edgecolor(rcParams['axes.edgecolor'])
  41. self.set_linewidth(rcParams['axes.linewidth'])
  42. self.set_capstyle('projecting')
  43. self.axis = None
  44. self.set_zorder(2.5)
  45. self.set_transform(self.axes.transData) # default transform
  46. self._bounds = None # default bounds
  47. self._smart_bounds = False
  48. # Defer initial position determination. (Not much support for
  49. # non-rectangular axes is currently implemented, and this lets
  50. # them pass through the spines machinery without errors.)
  51. self._position = None
  52. if not isinstance(path, matplotlib.path.Path):
  53. raise ValueError(
  54. "'path' must be an instance of 'matplotlib.path.Path'")
  55. self._path = path
  56. # To support drawing both linear and circular spines, this
  57. # class implements Patch behavior three ways. If
  58. # self._patch_type == 'line', behave like a mpatches.PathPatch
  59. # instance. If self._patch_type == 'circle', behave like a
  60. # mpatches.Ellipse instance. If self._patch_type == 'arc', behave like
  61. # a mpatches.Arc instance.
  62. self._patch_type = 'line'
  63. # Behavior copied from mpatches.Ellipse:
  64. # Note: This cannot be calculated until this is added to an Axes
  65. self._patch_transform = mtransforms.IdentityTransform()
  66. def set_smart_bounds(self, value):
  67. """set the spine and associated axis to have smart bounds"""
  68. self._smart_bounds = value
  69. # also set the axis if possible
  70. if self.spine_type in ('left', 'right'):
  71. self.axes.yaxis.set_smart_bounds(value)
  72. elif self.spine_type in ('top', 'bottom'):
  73. self.axes.xaxis.set_smart_bounds(value)
  74. self.stale = True
  75. def get_smart_bounds(self):
  76. """get whether the spine has smart bounds"""
  77. return self._smart_bounds
  78. def set_patch_arc(self, center, radius, theta1, theta2):
  79. """set the spine to be arc-like"""
  80. self._patch_type = 'arc'
  81. self._center = center
  82. self._width = radius * 2
  83. self._height = radius * 2
  84. self._theta1 = theta1
  85. self._theta2 = theta2
  86. self._path = mpath.Path.arc(theta1, theta2)
  87. # arc drawn on axes transform
  88. self.set_transform(self.axes.transAxes)
  89. self.stale = True
  90. def set_patch_circle(self, center, radius):
  91. """set the spine to be circular"""
  92. self._patch_type = 'circle'
  93. self._center = center
  94. self._width = radius * 2
  95. self._height = radius * 2
  96. # circle drawn on axes transform
  97. self.set_transform(self.axes.transAxes)
  98. self.stale = True
  99. def set_patch_line(self):
  100. """set the spine to be linear"""
  101. self._patch_type = 'line'
  102. self.stale = True
  103. # Behavior copied from mpatches.Ellipse:
  104. def _recompute_transform(self):
  105. """NOTE: This cannot be called until after this has been added
  106. to an Axes, otherwise unit conversion will fail. This
  107. makes it very important to call the accessor method and
  108. not directly access the transformation member variable.
  109. """
  110. assert self._patch_type in ('arc', 'circle')
  111. center = (self.convert_xunits(self._center[0]),
  112. self.convert_yunits(self._center[1]))
  113. width = self.convert_xunits(self._width)
  114. height = self.convert_yunits(self._height)
  115. self._patch_transform = mtransforms.Affine2D() \
  116. .scale(width * 0.5, height * 0.5) \
  117. .translate(*center)
  118. def get_patch_transform(self):
  119. if self._patch_type in ('arc', 'circle'):
  120. self._recompute_transform()
  121. return self._patch_transform
  122. else:
  123. return super().get_patch_transform()
  124. def get_window_extent(self, renderer=None):
  125. # make sure the location is updated so that transforms etc are
  126. # correct:
  127. self._adjust_location()
  128. return super().get_window_extent(renderer=renderer)
  129. def get_path(self):
  130. return self._path
  131. def _ensure_position_is_set(self):
  132. if self._position is None:
  133. # default position
  134. self._position = ('outward', 0.0) # in points
  135. self.set_position(self._position)
  136. def register_axis(self, axis):
  137. """register an axis
  138. An axis should be registered with its corresponding spine from
  139. the Axes instance. This allows the spine to clear any axis
  140. properties when needed.
  141. """
  142. self.axis = axis
  143. if self.axis is not None:
  144. self.axis.cla()
  145. self.stale = True
  146. def cla(self):
  147. """Clear the current spine"""
  148. self._position = None # clear position
  149. if self.axis is not None:
  150. self.axis.cla()
  151. def is_frame_like(self):
  152. """return True if directly on axes frame
  153. This is useful for determining if a spine is the edge of an
  154. old style MPL plot. If so, this function will return True.
  155. """
  156. self._ensure_position_is_set()
  157. position = self._position
  158. if isinstance(position, str):
  159. if position == 'center':
  160. position = ('axes', 0.5)
  161. elif position == 'zero':
  162. position = ('data', 0)
  163. if len(position) != 2:
  164. raise ValueError("position should be 2-tuple")
  165. position_type, amount = position
  166. if position_type == 'outward' and amount == 0:
  167. return True
  168. else:
  169. return False
  170. def _adjust_location(self):
  171. """automatically set spine bounds to the view interval"""
  172. if self.spine_type == 'circle':
  173. return
  174. if self._bounds is None:
  175. if self.spine_type in ('left', 'right'):
  176. low, high = self.axes.viewLim.intervaly
  177. elif self.spine_type in ('top', 'bottom'):
  178. low, high = self.axes.viewLim.intervalx
  179. else:
  180. raise ValueError('unknown spine spine_type: %s' %
  181. self.spine_type)
  182. if self._smart_bounds:
  183. # attempt to set bounds in sophisticated way
  184. # handle inverted limits
  185. viewlim_low, viewlim_high = sorted([low, high])
  186. if self.spine_type in ('left', 'right'):
  187. datalim_low, datalim_high = self.axes.dataLim.intervaly
  188. ticks = self.axes.get_yticks()
  189. elif self.spine_type in ('top', 'bottom'):
  190. datalim_low, datalim_high = self.axes.dataLim.intervalx
  191. ticks = self.axes.get_xticks()
  192. # handle inverted limits
  193. ticks = np.sort(ticks)
  194. datalim_low, datalim_high = sorted([datalim_low, datalim_high])
  195. if datalim_low < viewlim_low:
  196. # Data extends past view. Clip line to view.
  197. low = viewlim_low
  198. else:
  199. # Data ends before view ends.
  200. cond = (ticks <= datalim_low) & (ticks >= viewlim_low)
  201. tickvals = ticks[cond]
  202. if len(tickvals):
  203. # A tick is less than or equal to lowest data point.
  204. low = tickvals[-1]
  205. else:
  206. # No tick is available
  207. low = datalim_low
  208. low = max(low, viewlim_low)
  209. if datalim_high > viewlim_high:
  210. # Data extends past view. Clip line to view.
  211. high = viewlim_high
  212. else:
  213. # Data ends before view ends.
  214. cond = (ticks >= datalim_high) & (ticks <= viewlim_high)
  215. tickvals = ticks[cond]
  216. if len(tickvals):
  217. # A tick is greater than or equal to highest data
  218. # point.
  219. high = tickvals[0]
  220. else:
  221. # No tick is available
  222. high = datalim_high
  223. high = min(high, viewlim_high)
  224. else:
  225. low, high = self._bounds
  226. if self._patch_type == 'arc':
  227. if self.spine_type in ('bottom', 'top'):
  228. try:
  229. direction = self.axes.get_theta_direction()
  230. except AttributeError:
  231. direction = 1
  232. try:
  233. offset = self.axes.get_theta_offset()
  234. except AttributeError:
  235. offset = 0
  236. low = low * direction + offset
  237. high = high * direction + offset
  238. if low > high:
  239. low, high = high, low
  240. self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high))
  241. if self.spine_type == 'bottom':
  242. rmin, rmax = self.axes.viewLim.intervaly
  243. try:
  244. rorigin = self.axes.get_rorigin()
  245. except AttributeError:
  246. rorigin = rmin
  247. scaled_diameter = (rmin - rorigin) / (rmax - rorigin)
  248. self._height = scaled_diameter
  249. self._width = scaled_diameter
  250. else:
  251. raise ValueError('unable to set bounds for spine "%s"' %
  252. self.spine_type)
  253. else:
  254. v1 = self._path.vertices
  255. assert v1.shape == (2, 2), 'unexpected vertices shape'
  256. if self.spine_type in ['left', 'right']:
  257. v1[0, 1] = low
  258. v1[1, 1] = high
  259. elif self.spine_type in ['bottom', 'top']:
  260. v1[0, 0] = low
  261. v1[1, 0] = high
  262. else:
  263. raise ValueError('unable to set bounds for spine "%s"' %
  264. self.spine_type)
  265. @allow_rasterization
  266. def draw(self, renderer):
  267. self._adjust_location()
  268. ret = super().draw(renderer)
  269. self.stale = False
  270. return ret
  271. def _calc_offset_transform(self):
  272. """calculate the offset transform performed by the spine"""
  273. self._ensure_position_is_set()
  274. position = self._position
  275. if isinstance(position, str):
  276. if position == 'center':
  277. position = ('axes', 0.5)
  278. elif position == 'zero':
  279. position = ('data', 0)
  280. assert len(position) == 2, "position should be 2-tuple"
  281. position_type, amount = position
  282. assert position_type in ('axes', 'outward', 'data')
  283. if position_type == 'outward':
  284. if amount == 0:
  285. # short circuit commonest case
  286. self._spine_transform = ('identity',
  287. mtransforms.IdentityTransform())
  288. elif self.spine_type in ['left', 'right', 'top', 'bottom']:
  289. offset_vec = {'left': (-1, 0),
  290. 'right': (1, 0),
  291. 'bottom': (0, -1),
  292. 'top': (0, 1),
  293. }[self.spine_type]
  294. # calculate x and y offset in dots
  295. offset_x = amount * offset_vec[0] / 72.0
  296. offset_y = amount * offset_vec[1] / 72.0
  297. self._spine_transform = ('post',
  298. mtransforms.ScaledTranslation(
  299. offset_x,
  300. offset_y,
  301. self.figure.dpi_scale_trans))
  302. else:
  303. warnings.warn('unknown spine type "%s": no spine '
  304. 'offset performed' % self.spine_type)
  305. self._spine_transform = ('identity',
  306. mtransforms.IdentityTransform())
  307. elif position_type == 'axes':
  308. if self.spine_type in ('left', 'right'):
  309. self._spine_transform = ('pre',
  310. mtransforms.Affine2D.from_values(
  311. # keep y unchanged, fix x at
  312. # amount
  313. 0, 0, 0, 1, amount, 0))
  314. elif self.spine_type in ('bottom', 'top'):
  315. self._spine_transform = ('pre',
  316. mtransforms.Affine2D.from_values(
  317. # keep x unchanged, fix y at
  318. # amount
  319. 1, 0, 0, 0, 0, amount))
  320. else:
  321. warnings.warn('unknown spine type "%s": no spine '
  322. 'offset performed' % self.spine_type)
  323. self._spine_transform = ('identity',
  324. mtransforms.IdentityTransform())
  325. elif position_type == 'data':
  326. if self.spine_type in ('right', 'top'):
  327. # The right and top spines have a default position of 1 in
  328. # axes coordinates. When specifying the position in data
  329. # coordinates, we need to calculate the position relative to 0.
  330. amount -= 1
  331. if self.spine_type in ('left', 'right'):
  332. self._spine_transform = ('data',
  333. mtransforms.Affine2D().translate(
  334. amount, 0))
  335. elif self.spine_type in ('bottom', 'top'):
  336. self._spine_transform = ('data',
  337. mtransforms.Affine2D().translate(
  338. 0, amount))
  339. else:
  340. warnings.warn('unknown spine type "%s": no spine '
  341. 'offset performed' % self.spine_type)
  342. self._spine_transform = ('identity',
  343. mtransforms.IdentityTransform())
  344. def set_position(self, position):
  345. """set the position of the spine
  346. Spine position is specified by a 2 tuple of (position type,
  347. amount). The position types are:
  348. * 'outward' : place the spine out from the data area by the
  349. specified number of points. (Negative values specify placing the
  350. spine inward.)
  351. * 'axes' : place the spine at the specified Axes coordinate (from
  352. 0.0-1.0).
  353. * 'data' : place the spine at the specified data coordinate.
  354. Additionally, shorthand notations define a special positions:
  355. * 'center' -> ('axes',0.5)
  356. * 'zero' -> ('data', 0.0)
  357. """
  358. if position in ('center', 'zero'):
  359. # special positions
  360. pass
  361. else:
  362. if len(position) != 2:
  363. raise ValueError("position should be 'center' or 2-tuple")
  364. if position[0] not in ['outward', 'axes', 'data']:
  365. raise ValueError("position[0] should be one of 'outward', "
  366. "'axes', or 'data' ")
  367. self._position = position
  368. self._calc_offset_transform()
  369. self.set_transform(self.get_spine_transform())
  370. if self.axis is not None:
  371. self.axis.reset_ticks()
  372. self.stale = True
  373. def get_position(self):
  374. """get the spine position"""
  375. self._ensure_position_is_set()
  376. return self._position
  377. def get_spine_transform(self):
  378. """get the spine transform"""
  379. self._ensure_position_is_set()
  380. what, how = self._spine_transform
  381. if what == 'data':
  382. # special case data based spine locations
  383. data_xform = self.axes.transScale + \
  384. (how + self.axes.transLimits + self.axes.transAxes)
  385. if self.spine_type in ['left', 'right']:
  386. result = mtransforms.blended_transform_factory(
  387. data_xform, self.axes.transData)
  388. elif self.spine_type in ['top', 'bottom']:
  389. result = mtransforms.blended_transform_factory(
  390. self.axes.transData, data_xform)
  391. else:
  392. raise ValueError('unknown spine spine_type: %s' %
  393. self.spine_type)
  394. return result
  395. if self.spine_type in ['left', 'right']:
  396. base_transform = self.axes.get_yaxis_transform(which='grid')
  397. elif self.spine_type in ['top', 'bottom']:
  398. base_transform = self.axes.get_xaxis_transform(which='grid')
  399. else:
  400. raise ValueError('unknown spine spine_type: %s' %
  401. self.spine_type)
  402. if what == 'identity':
  403. return base_transform
  404. elif what == 'post':
  405. return base_transform + how
  406. elif what == 'pre':
  407. return how + base_transform
  408. else:
  409. raise ValueError("unknown spine_transform type: %s" % what)
  410. def set_bounds(self, low, high):
  411. """Set the bounds of the spine."""
  412. if self.spine_type == 'circle':
  413. raise ValueError(
  414. 'set_bounds() method incompatible with circular spines')
  415. self._bounds = (low, high)
  416. self.stale = True
  417. def get_bounds(self):
  418. """Get the bounds of the spine."""
  419. return self._bounds
  420. @classmethod
  421. def linear_spine(cls, axes, spine_type, **kwargs):
  422. """
  423. (staticmethod) Returns a linear :class:`Spine`.
  424. """
  425. # all values of 0.999 get replaced upon call to set_bounds()
  426. if spine_type == 'left':
  427. path = mpath.Path([(0.0, 0.999), (0.0, 0.999)])
  428. elif spine_type == 'right':
  429. path = mpath.Path([(1.0, 0.999), (1.0, 0.999)])
  430. elif spine_type == 'bottom':
  431. path = mpath.Path([(0.999, 0.0), (0.999, 0.0)])
  432. elif spine_type == 'top':
  433. path = mpath.Path([(0.999, 1.0), (0.999, 1.0)])
  434. else:
  435. raise ValueError('unable to make path for spine "%s"' % spine_type)
  436. result = cls(axes, spine_type, path, **kwargs)
  437. result.set_visible(rcParams['axes.spines.{0}'.format(spine_type)])
  438. return result
  439. @classmethod
  440. def arc_spine(cls, axes, spine_type, center, radius, theta1, theta2,
  441. **kwargs):
  442. """
  443. (classmethod) Returns an arc :class:`Spine`.
  444. """
  445. path = mpath.Path.arc(theta1, theta2)
  446. result = cls(axes, spine_type, path, **kwargs)
  447. result.set_patch_arc(center, radius, theta1, theta2)
  448. return result
  449. @classmethod
  450. def circular_spine(cls, axes, center, radius, **kwargs):
  451. """
  452. (staticmethod) Returns a circular :class:`Spine`.
  453. """
  454. path = mpath.Path.unit_circle()
  455. spine_type = 'circle'
  456. result = cls(axes, spine_type, path, **kwargs)
  457. result.set_patch_circle(center, radius)
  458. return result
  459. def set_color(self, c):
  460. """
  461. Set the edgecolor.
  462. Parameters
  463. ----------
  464. c : color or sequence of rgba tuples
  465. .. seealso::
  466. :meth:`set_facecolor`, :meth:`set_edgecolor`
  467. For setting the edge or face color individually.
  468. """
  469. # The facecolor of a spine is always 'none' by default -- let
  470. # the user change it manually if desired.
  471. self.set_edgecolor(c)
  472. self.stale = True