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