|
| 1 | +=============================================== |
| 2 | +Adding new scales and projections to matplotlib |
| 3 | +=============================================== |
| 4 | + |
| 5 | +.. ::author Michael Droettboom |
| 6 | +
|
| 7 | +Matplotlib supports the addition of new transformations that transform |
| 8 | +the data before it is displayed. Separable transformations, that work |
| 9 | +on a single dimension are called "scales", and non-separable |
| 10 | +transformations, that take data in two or more dimensions as input are |
| 11 | +called "projections". |
| 12 | + |
| 13 | +This document is intended for developers and advanced users who need |
| 14 | +to add more scales and projections to matplotlib. |
| 15 | + |
| 16 | +From the user's perspective, the scale of a plot can be set with |
| 17 | +``set_xscale`` and ``set_yscale``. Choosing the projection |
| 18 | +currently has no *standardized* method. [MGDTODO] |
| 19 | + |
| 20 | +Creating a new scale |
| 21 | +==================== |
| 22 | + |
| 23 | +Adding a new scale consists of defining a subclass of ``ScaleBase``, |
| 24 | +that brings together the following elements: |
| 25 | + |
| 26 | + - A transformation from data space into plot space. |
| 27 | + |
| 28 | + - An inverse of that transformation. For example, this is used to |
| 29 | + convert mouse positions back into data space. |
| 30 | + |
| 31 | + - A function to limit the range of the axis to acceptable values. A |
| 32 | + log scale, for instance, would prevent the range from including |
| 33 | + values less than or equal to zero. |
| 34 | + |
| 35 | + - Locators (major and minor) that determine where to place ticks in |
| 36 | + the plot, and optionally, how to adjust the limits of the plot to |
| 37 | + some "good" values. |
| 38 | + |
| 39 | + - Formatters (major and minor) that specify how the tick labels |
| 40 | + should be drawn. |
| 41 | + |
| 42 | +There are a number of ``Scale`` classes in ``scale.py`` that may be |
| 43 | +used as starting points for new scales. As an example, this document |
| 44 | +presents adding a new scale ``MercatorLatitudeScale`` which can be |
| 45 | +used to plot latitudes in a Mercator_ projection. For simplicity, |
| 46 | +this scale assumes that it has a fixed center at the equator. The |
| 47 | +code presented here is a simplification of actual code in |
| 48 | +``matplotlib``, with complications added only for the sake of |
| 49 | +optimization removed. |
| 50 | + |
| 51 | +First define a new subclass of ``ScaleBase``:: |
| 52 | + |
| 53 | + class MercatorLatitudeScale(ScaleBase): |
| 54 | + """ |
| 55 | + Scales data in range -pi/2 to pi/2 (-90 to 90 degrees) using |
| 56 | + the system used to scale latitudes in a Mercator projection. |
| 57 | + |
| 58 | + The scale function: |
| 59 | + ln(tan(y) + sec(y)) |
| 60 | + |
| 61 | + The inverse scale function: |
| 62 | + atan(sinh(y)) |
| 63 | + |
| 64 | + Since the Mercator scale tends to infinity at +/- 90 degrees, |
| 65 | + there is user-defined threshold, above and below which nothing |
| 66 | + will be plotted. This defaults to +/- 85 degrees. |
| 67 | + |
| 68 | + source: |
| 69 | + http://en.wikipedia.org/wiki/Mercator_projection |
| 70 | + """ |
| 71 | + name = 'mercator_latitude' |
| 72 | + |
| 73 | +This class must have a member ``name`` that defines the string used to |
| 74 | +select the scale. For example, |
| 75 | +``gca().set_yscale("mercator_latitude")`` would be used to select the |
| 76 | +Mercator latitude scale. |
| 77 | + |
| 78 | +Next define two nested classes: one for the data transformation and |
| 79 | +one for its inverse. Both of these classes must be subclasses of |
| 80 | +``Transform`` (defined in ``transforms.py``).:: |
| 81 | + |
| 82 | + class MercatorLatitudeTransform(Transform): |
| 83 | + input_dims = 1 |
| 84 | + output_dims = 1 |
| 85 | + |
| 86 | +There are two class-members that must be defined. ``input_dims`` and |
| 87 | +``output_dims`` specify number of input dimensions and output |
| 88 | +dimensions to the transformation. These are used by the |
| 89 | +transformation framework to do some error checking and prevent |
| 90 | +incompatible transformations from being connected together. When |
| 91 | +defining transforms for a scale, which are by definition separable and |
| 92 | +only have one dimension, these members should always be 1. |
| 93 | + |
| 94 | +``MercatorLatitudeTransform`` has a simple constructor that takes and |
| 95 | +stores the *threshold* for the Mercator projection (to limit its range |
| 96 | +to prevent plotting to infinity).:: |
| 97 | + |
| 98 | + def __init__(self, thresh): |
| 99 | + Transform.__init__(self) |
| 100 | + self.thresh = thresh |
| 101 | + |
| 102 | +The ``transform`` method is where the real work happens: It takes an N |
| 103 | +x 1 ``numpy`` array and returns a transformed copy. Since the range |
| 104 | +of the Mercator scale is limited by the user-specified threshold, the |
| 105 | +input array must be masked to contain only valid values. |
| 106 | +``matplotlib`` will handle masked arrays and remove the out-of-range |
| 107 | +data from the plot. Importantly, the transformation should return an |
| 108 | +array that is the same shape as the input array, since these values |
| 109 | +need to remain synchronized with values in the other dimension.:: |
| 110 | + |
| 111 | + def transform(self, a): |
| 112 | + masked = ma.masked_where((a < -self.thresh) | (a > self.thresh), a) |
| 113 | + return ma.log(ma.abs(ma.tan(masked) + 1.0 / ma.cos(masked))) |
| 114 | + |
| 115 | +Lastly for the transformation class, define a method to get the |
| 116 | +inverse transformation:: |
| 117 | + |
| 118 | + def inverted(self): |
| 119 | + return MercatorLatitudeScale.InvertedMercatorLatitudeTransform(self.thresh) |
| 120 | + |
| 121 | +The inverse transformation class follows the same pattern, but |
| 122 | +obviously the mathematical operation performed is different:: |
| 123 | + |
| 124 | + class InvertedMercatorLatitudeTransform(Transform): |
| 125 | + input_dims = 1 |
| 126 | + output_dims = 1 |
| 127 | + |
| 128 | + def __init__(self, thresh): |
| 129 | + Transform.__init__(self) |
| 130 | + self.thresh = thresh |
| 131 | + |
| 132 | + def transform(self, a): |
| 133 | + return npy.arctan(npy.sinh(a)) |
| 134 | + |
| 135 | + def inverted(self): |
| 136 | + return MercatorLatitudeScale.MercatorLatitudeTransform(self.thresh) |
| 137 | + |
| 138 | +Now we're back to methods for the ``MercatorLatitudeScale`` class. |
| 139 | +Any keyword arguments passed to ``set_xscale`` and ``set_yscale`` will |
| 140 | +be passed along to the scale's constructor. In the case of |
| 141 | +``MercatorLatitudeScale``, the ``thresh`` keyword argument specifies |
| 142 | +the degree at which to crop the plot data. The constructor also |
| 143 | +creates a local instance of the ``Transform`` class defined above, |
| 144 | +which is made available through its ``get_transform`` method:: |
| 145 | + |
| 146 | + def __init__(self, axis, **kwargs): |
| 147 | + thresh = kwargs.pop("thresh", (85 / 180.0) * npy.pi) |
| 148 | + if thresh >= npy.pi / 2.0: |
| 149 | + raise ValueError("thresh must be less than pi/2") |
| 150 | + self.thresh = thresh |
| 151 | + self._transform = self.MercatorLatitudeTransform(thresh) |
| 152 | + |
| 153 | + def get_transform(self): |
| 154 | + return self._transform |
| 155 | + |
| 156 | +The ``limit_range_for_scale`` method must be provided to limit the |
| 157 | +bounds of the axis to the domain of the function. In the case of |
| 158 | +Mercator, the bounds should be limited to the threshold that was |
| 159 | +passed in. Unlike the autoscaling provided by the tick locators, this |
| 160 | +range limiting will always be adhered to, whether the axis range is set |
| 161 | +manually, determined automatically or changed through panning and |
| 162 | +zooming:: |
| 163 | + |
| 164 | + def limit_range_for_scale(self, vmin, vmax, minpos): |
| 165 | + return max(vmin, -self.thresh), min(vmax, self.thresh) |
| 166 | + |
| 167 | +Lastly, the ``set_default_locators_and_formatters`` method sets up the |
| 168 | +locators and formatters to use with the scale. It may be that the new |
| 169 | +scale requires new locators and formatters. Doing so is outside the |
| 170 | +scope of this document, but there are many examples in ``ticker.py``. |
| 171 | +The Mercator example uses a fixed locator from -90 to 90 degrees and a |
| 172 | +custom formatter class to put convert the radians to degrees and put a |
| 173 | +degree symbol after the value:: |
| 174 | + |
| 175 | + def set_default_locators_and_formatters(self, axis): |
| 176 | + class DegreeFormatter(Formatter): |
| 177 | + def __call__(self, x, pos=None): |
| 178 | + # \u00b0 : degree symbol |
| 179 | + return u"%d\u00b0" % ((x / npy.pi) * 180.0) |
| 180 | + |
| 181 | + deg2rad = npy.pi / 180.0 |
| 182 | + axis.set_major_locator(FixedLocator( |
| 183 | + npy.arange(-90, 90, 10) * deg2rad)) |
| 184 | + axis.set_major_formatter(DegreeFormatter()) |
| 185 | + axis.set_minor_formatter(DegreeFormatter()) |
| 186 | + |
| 187 | +Now that the Scale class has been defined, it must be registered so |
| 188 | +that ``matplotlib`` can find it:: |
| 189 | + |
| 190 | + register_scale(MercatorLatitudeScale) |
| 191 | + |
| 192 | +.. _Mercator: http://en.wikipedia.org/wiki/Mercator_projection |
0 commit comments