spines.py 21 KB

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