_secondary_axes.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import numbers
  2. import numpy as np
  3. from matplotlib import _api, _docstring, transforms
  4. import matplotlib.ticker as mticker
  5. from matplotlib.axes._base import _AxesBase, _TransformedBoundsLocator
  6. from matplotlib.axis import Axis
  7. from matplotlib.transforms import Transform
  8. class SecondaryAxis(_AxesBase):
  9. """
  10. General class to hold a Secondary_X/Yaxis.
  11. """
  12. def __init__(self, parent, orientation, location, functions, transform=None,
  13. **kwargs):
  14. """
  15. See `.secondary_xaxis` and `.secondary_yaxis` for the doc string.
  16. While there is no need for this to be private, it should really be
  17. called by those higher level functions.
  18. """
  19. _api.check_in_list(["x", "y"], orientation=orientation)
  20. self._functions = functions
  21. self._parent = parent
  22. self._orientation = orientation
  23. self._ticks_set = False
  24. fig = self._parent.get_figure(root=False)
  25. if self._orientation == 'x':
  26. super().__init__(fig, [0, 1., 1, 0.0001], **kwargs)
  27. self._axis = self.xaxis
  28. self._locstrings = ['top', 'bottom']
  29. self._otherstrings = ['left', 'right']
  30. else: # 'y'
  31. super().__init__(fig, [0, 1., 0.0001, 1], **kwargs)
  32. self._axis = self.yaxis
  33. self._locstrings = ['right', 'left']
  34. self._otherstrings = ['top', 'bottom']
  35. self._parentscale = None
  36. # this gets positioned w/o constrained_layout so exclude:
  37. self.set_location(location, transform)
  38. self.set_functions(functions)
  39. # styling:
  40. otheraxis = self.yaxis if self._orientation == 'x' else self.xaxis
  41. otheraxis.set_major_locator(mticker.NullLocator())
  42. otheraxis.set_ticks_position('none')
  43. self.spines[self._otherstrings].set_visible(False)
  44. self.spines[self._locstrings].set_visible(True)
  45. if self._pos < 0.5:
  46. # flip the location strings...
  47. self._locstrings = self._locstrings[::-1]
  48. self.set_alignment(self._locstrings[0])
  49. def set_alignment(self, align):
  50. """
  51. Set if axes spine and labels are drawn at top or bottom (or left/right)
  52. of the Axes.
  53. Parameters
  54. ----------
  55. align : {'top', 'bottom', 'left', 'right'}
  56. Either 'top' or 'bottom' for orientation='x' or
  57. 'left' or 'right' for orientation='y' axis.
  58. """
  59. _api.check_in_list(self._locstrings, align=align)
  60. if align == self._locstrings[1]: # Need to change the orientation.
  61. self._locstrings = self._locstrings[::-1]
  62. self.spines[self._locstrings[0]].set_visible(True)
  63. self.spines[self._locstrings[1]].set_visible(False)
  64. self._axis.set_ticks_position(align)
  65. self._axis.set_label_position(align)
  66. def set_location(self, location, transform=None):
  67. """
  68. Set the vertical or horizontal location of the axes in
  69. parent-normalized coordinates.
  70. Parameters
  71. ----------
  72. location : {'top', 'bottom', 'left', 'right'} or float
  73. The position to put the secondary axis. Strings can be 'top' or
  74. 'bottom' for orientation='x' and 'right' or 'left' for
  75. orientation='y'. A float indicates the relative position on the
  76. parent Axes to put the new Axes, 0.0 being the bottom (or left)
  77. and 1.0 being the top (or right).
  78. transform : `.Transform`, optional
  79. Transform for the location to use. Defaults to
  80. the parent's ``transAxes``, so locations are normally relative to
  81. the parent axes.
  82. .. versionadded:: 3.9
  83. """
  84. _api.check_isinstance((transforms.Transform, None), transform=transform)
  85. # This puts the rectangle into figure-relative coordinates.
  86. if isinstance(location, str):
  87. _api.check_in_list(self._locstrings, location=location)
  88. self._pos = 1. if location in ('top', 'right') else 0.
  89. elif isinstance(location, numbers.Real):
  90. self._pos = location
  91. else:
  92. raise ValueError(
  93. f"location must be {self._locstrings[0]!r}, "
  94. f"{self._locstrings[1]!r}, or a float, not {location!r}")
  95. self._loc = location
  96. if self._orientation == 'x':
  97. # An x-secondary axes is like an inset axes from x = 0 to x = 1 and
  98. # from y = pos to y = pos + eps, in the parent's transAxes coords.
  99. bounds = [0, self._pos, 1., 1e-10]
  100. # If a transformation is provided, use its y component rather than
  101. # the parent's transAxes. This can be used to place axes in the data
  102. # coords, for instance.
  103. if transform is not None:
  104. transform = transforms.blended_transform_factory(
  105. self._parent.transAxes, transform)
  106. else: # 'y'
  107. bounds = [self._pos, 0, 1e-10, 1]
  108. if transform is not None:
  109. transform = transforms.blended_transform_factory(
  110. transform, self._parent.transAxes) # Use provided x axis
  111. # If no transform is provided, use the parent's transAxes
  112. if transform is None:
  113. transform = self._parent.transAxes
  114. # this locator lets the axes move in the parent axes coordinates.
  115. # so it never needs to know where the parent is explicitly in
  116. # figure coordinates.
  117. # it gets called in ax.apply_aspect() (of all places)
  118. self.set_axes_locator(_TransformedBoundsLocator(bounds, transform))
  119. def apply_aspect(self, position=None):
  120. # docstring inherited.
  121. self._set_lims()
  122. super().apply_aspect(position)
  123. @_docstring.copy(Axis.set_ticks)
  124. def set_ticks(self, ticks, labels=None, *, minor=False, **kwargs):
  125. ret = self._axis.set_ticks(ticks, labels, minor=minor, **kwargs)
  126. self.stale = True
  127. self._ticks_set = True
  128. return ret
  129. def set_functions(self, functions):
  130. """
  131. Set how the secondary axis converts limits from the parent Axes.
  132. Parameters
  133. ----------
  134. functions : 2-tuple of func, or `Transform` with an inverse.
  135. Transform between the parent axis values and the secondary axis
  136. values.
  137. If supplied as a 2-tuple of functions, the first function is
  138. the forward transform function and the second is the inverse
  139. transform.
  140. If a transform is supplied, then the transform must have an
  141. inverse.
  142. """
  143. if (isinstance(functions, tuple) and len(functions) == 2 and
  144. callable(functions[0]) and callable(functions[1])):
  145. # make an arbitrary convert from a two-tuple of functions
  146. # forward and inverse.
  147. self._functions = functions
  148. elif isinstance(functions, Transform):
  149. self._functions = (
  150. functions.transform,
  151. lambda x: functions.inverted().transform(x)
  152. )
  153. elif functions is None:
  154. self._functions = (lambda x: x, lambda x: x)
  155. else:
  156. raise ValueError('functions argument of secondary Axes '
  157. 'must be a two-tuple of callable functions '
  158. 'with the first function being the transform '
  159. 'and the second being the inverse')
  160. self._set_scale()
  161. def draw(self, renderer):
  162. """
  163. Draw the secondary Axes.
  164. Consults the parent Axes for its limits and converts them
  165. using the converter specified by
  166. `~.axes._secondary_axes.set_functions` (or *functions*
  167. parameter when Axes initialized.)
  168. """
  169. self._set_lims()
  170. # this sets the scale in case the parent has set its scale.
  171. self._set_scale()
  172. super().draw(renderer)
  173. def _set_scale(self):
  174. """
  175. Check if parent has set its scale
  176. """
  177. if self._orientation == 'x':
  178. pscale = self._parent.xaxis.get_scale()
  179. set_scale = self.set_xscale
  180. else: # 'y'
  181. pscale = self._parent.yaxis.get_scale()
  182. set_scale = self.set_yscale
  183. if pscale == self._parentscale:
  184. return
  185. if self._ticks_set:
  186. ticks = self._axis.get_ticklocs()
  187. # need to invert the roles here for the ticks to line up.
  188. set_scale('functionlog' if pscale == 'log' else 'function',
  189. functions=self._functions[::-1])
  190. # OK, set_scale sets the locators, but if we've called
  191. # axsecond.set_ticks, we want to keep those.
  192. if self._ticks_set:
  193. self._axis.set_major_locator(mticker.FixedLocator(ticks))
  194. # If the parent scale doesn't change, we can skip this next time.
  195. self._parentscale = pscale
  196. def _set_lims(self):
  197. """
  198. Set the limits based on parent limits and the convert method
  199. between the parent and this secondary Axes.
  200. """
  201. if self._orientation == 'x':
  202. lims = self._parent.get_xlim()
  203. set_lim = self.set_xlim
  204. else: # 'y'
  205. lims = self._parent.get_ylim()
  206. set_lim = self.set_ylim
  207. order = lims[0] < lims[1]
  208. lims = self._functions[0](np.array(lims))
  209. neworder = lims[0] < lims[1]
  210. if neworder != order:
  211. # Flip because the transform will take care of the flipping.
  212. lims = lims[::-1]
  213. set_lim(lims)
  214. def set_aspect(self, *args, **kwargs):
  215. """
  216. Secondary Axes cannot set the aspect ratio, so calling this just
  217. sets a warning.
  218. """
  219. _api.warn_external("Secondary Axes can't set the aspect ratio")
  220. def set_color(self, color):
  221. """
  222. Change the color of the secondary Axes and all decorators.
  223. Parameters
  224. ----------
  225. color : :mpltype:`color`
  226. """
  227. axis = self._axis_map[self._orientation]
  228. axis.set_tick_params(colors=color)
  229. for spine in self.spines.values():
  230. if spine.axis is axis:
  231. spine.set_color(color)
  232. axis.label.set_color(color)
  233. _secax_docstring = '''
  234. Warnings
  235. --------
  236. This method is experimental as of 3.1, and the API may change.
  237. Parameters
  238. ----------
  239. location : {'top', 'bottom', 'left', 'right'} or float
  240. The position to put the secondary axis. Strings can be 'top' or
  241. 'bottom' for orientation='x' and 'right' or 'left' for
  242. orientation='y'. A float indicates the relative position on the
  243. parent Axes to put the new Axes, 0.0 being the bottom (or left)
  244. and 1.0 being the top (or right).
  245. functions : 2-tuple of func, or Transform with an inverse
  246. If a 2-tuple of functions, the user specifies the transform
  247. function and its inverse. i.e.
  248. ``functions=(lambda x: 2 / x, lambda x: 2 / x)`` would be an
  249. reciprocal transform with a factor of 2. Both functions must accept
  250. numpy arrays as input.
  251. The user can also directly supply a subclass of
  252. `.transforms.Transform` so long as it has an inverse.
  253. See :doc:`/gallery/subplots_axes_and_figures/secondary_axis`
  254. for examples of making these conversions.
  255. transform : `.Transform`, optional
  256. If specified, *location* will be
  257. placed relative to this transform (in the direction of the axis)
  258. rather than the parent's axis. i.e. a secondary x-axis will
  259. use the provided y transform and the x transform of the parent.
  260. .. versionadded:: 3.9
  261. Returns
  262. -------
  263. ax : axes._secondary_axes.SecondaryAxis
  264. Other Parameters
  265. ----------------
  266. **kwargs : `~matplotlib.axes.Axes` properties.
  267. Other miscellaneous Axes parameters.
  268. '''
  269. _docstring.interpd.register(_secax_docstring=_secax_docstring)