From: <md...@us...> - 2008-01-07 21:16:07
|
Revision: 4803 http://matplotlib.svn.sourceforge.net/matplotlib/?rev=4803&view=rev Author: mdboom Date: 2008-01-07 13:15:58 -0800 (Mon, 07 Jan 2008) Log Message: ----------- Provide heavily-documented examples for adding new scales and projections. Fix various bugs related to non-rectangular clipping. Remove MercatorLatitude scale from core and put it in an example. Modified Paths: -------------- branches/transforms/doc/devel/add_new_projection.rst branches/transforms/lib/matplotlib/axes.py branches/transforms/lib/matplotlib/patches.py branches/transforms/lib/matplotlib/projections/__init__.py branches/transforms/lib/matplotlib/projections/geo.py branches/transforms/lib/matplotlib/projections/polar.py branches/transforms/lib/matplotlib/scale.py Added Paths: ----------- branches/transforms/examples/custom_projection_example.py branches/transforms/examples/custom_scale_example.py Modified: branches/transforms/doc/devel/add_new_projection.rst =================================================================== --- branches/transforms/doc/devel/add_new_projection.rst 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/doc/devel/add_new_projection.rst 2008-01-07 21:15:58 UTC (rev 4803) @@ -4,190 +4,99 @@ .. ::author Michael Droettboom -Matplotlib supports the addition of new transformations that transform -the data before it is displayed. In ``matplotlib`` nomenclature, -separable transformations, working on a single dimension, are called -"scales", and non-separable transformations, that take handle data in -two or more dimensions at a time, are called "projections". +Matplotlib supports the addition of custom procedures that transform +the data before it is displayed. +There is an important distinction between two kinds of +transformations. Separable transformations, working on a single +dimension, are called "scales", and non-separable transformations, +that handle data in two or more dimensions at a time, are called +"projections". + From the user's perspective, the scale of a plot can be set with -``set_xscale`` and ``set_yscale``. Choosing the projection -currently has no *standardized* method. [MGDTODO] +``set_xscale()`` and ``set_yscale()``. Projections can be chosen using +the ``projection`` keyword argument to the ``plot()`` or ``subplot()`` +functions:: + plot(x, y, projection="custom") + This document is intended for developers and advanced users who need -to add more scales and projections to matplotlib. +to create new scales and projections for matplotlib. The necessary +code for scales and projections can be included anywhere: directly +within a plot script, in third-party code, or in the matplotlib source +tree itself. Creating a new scale ==================== -Adding a new scale consists of defining a subclass of ``ScaleBase``, -that brings together the following elements: +Adding a new scale consists of defining a subclass of ``ScaleBase`` +(in the ``matplotlib.scale`` module), that includes the following +elements: - - A transformation from data space into plot space. + - A transformation from data coordinates into display coordinates. - - An inverse of that transformation. For example, this is used to - convert mouse positions back into data space. + - An inverse of that transformation. This is used, for example, to + convert mouse positions from screen space back into data space. - - A function to limit the range of the axis to acceptable values. A - log scale, for instance, would prevent the range from including - values less than or equal to zero. + - A function to limit the range of the axis to acceptable values + (``limit_range_for_scale()``). A log scale, for instance, would + prevent the range from including values less than or equal to + zero. - Locators (major and minor) that determine where to place ticks in the plot, and optionally, how to adjust the limits of the plot to - some "good" values. + some "good" values. Unlike ``limit_range_for_scale()``, which is + always enforced, the range setting here is only used when + automatically setting the range of the plot. - Formatters (major and minor) that specify how the tick labels should be drawn. -There are a number of ``Scale`` classes in ``scale.py`` that may be -used as starting points for new scales. As an example, this document -presents adding a new scale ``MercatorLatitudeScale`` which can be -used to plot latitudes in a Mercator_ projection. For simplicity, -this scale assumes that it has a fixed center at the equator. The -code presented here is a simplification of actual code in -``matplotlib``, with complications added only for the sake of -optimization removed. +Once the class is defined, it must be registered with ``matplotlib`` +so that the user can select it. -First define a new subclass of ``ScaleBase``:: +A full-fledged and heavily annotated example is in +``examples/custom_scale_example.py``. There are also some ``Scale`` +classes in ``scale.py`` that may be used as starting points. - class MercatorLatitudeScale(ScaleBase): - """ - Scales data in range -pi/2 to pi/2 (-90 to 90 degrees) using - the system used to scale latitudes in a Mercator projection. - The scale function: - ln(tan(y) + sec(y)) +Creating a new projection +========================= - The inverse scale function: - atan(sinh(y)) +Adding a new projection consists of defining a subclass of ``Axes`` +(in the ``matplotlib.axes`` module), that includes the following +elements: - Since the Mercator scale tends to infinity at +/- 90 degrees, - there is user-defined threshold, above and below which nothing - will be plotted. This defaults to +/- 85 degrees. + - A transformation from data coordinates into display coordinates. - source: - http://en.wikipedia.org/wiki/Mercator_projection - """ - name = 'mercator_latitude' + - An inverse of that transformation. This is used, for example, to + convert mouse positions from screen space back into data space. -This class must have a member ``name`` that defines the string used to -select the scale. For example, -``gca().set_yscale("mercator_latitude")`` would be used to select the -Mercator latitude scale. + - Transformations for the gridlines, ticks and ticklabels. Custom + projections will often need to place these elements in special + locations, and ``matplotlib`` has a facility to help with doing so. -Next define two nested classes: one for the data transformation and -one for its inverse. Both of these classes must be subclasses of -``Transform`` (defined in ``transforms.py``).:: + - Setting up default values (overriding ``cla()``), since the + defaults for a rectilinear axes may not be appropriate. - class MercatorLatitudeTransform(Transform): - input_dims = 1 - output_dims = 1 + - Defining the shape of the axes, for example, an elliptical axes, + that will be used to draw the background of the plot and for + clipping any data elements. -There are two class-members that must be defined. ``input_dims`` and -``output_dims`` specify number of input dimensions and output -dimensions to the transformation. These are used by the -transformation framework to do some error checking and prevent -incompatible transformations from being connected together. When -defining transforms for a scale, which are by definition separable and -only have one dimension, these members should always be 1. + - Defining custom locators and formatters for the projection. For + example, in a geographic projection, it may be more convenient to + display the grid in degrees, even if the data is in radians. -``MercatorLatitudeTransform`` has a simple constructor that takes and -stores the *threshold* for the Mercator projection (to limit its range -to prevent plotting to infinity).:: + - Set up interactive panning and zooming. This is left as an + "advanced" feature left to the reader, but there is an example of + this for polar plots in ``polar.py``. - def __init__(self, thresh): - Transform.__init__(self) - self.thresh = thresh + - Any additional methods for additional convenience or features. -The ``transform`` method is where the real work happens: It takes an N -x 1 ``numpy`` array and returns a transformed copy. Since the range -of the Mercator scale is limited by the user-specified threshold, the -input array must be masked to contain only valid values. -``matplotlib`` will handle masked arrays and remove the out-of-range -data from the plot. Importantly, the transformation should return an -array that is the same shape as the input array, since these values -need to remain synchronized with values in the other dimension.:: +Once the class is defined, it must be registered with ``matplotlib`` +so that the user can select it. - def transform(self, a): - masked = ma.masked_where((a < -self.thresh) | (a > self.thresh), a) - return ma.log(ma.abs(ma.tan(masked) + 1.0 / ma.cos(masked))) - -Lastly for the transformation class, define a method to get the -inverse transformation:: - - def inverted(self): - return MercatorLatitudeScale.InvertedMercatorLatitudeTransform(self.thresh) - -The inverse transformation class follows the same pattern, but -obviously the mathematical operation performed is different:: - - class InvertedMercatorLatitudeTransform(Transform): - input_dims = 1 - output_dims = 1 - - def __init__(self, thresh): - Transform.__init__(self) - self.thresh = thresh - - def transform(self, a): - return npy.arctan(npy.sinh(a)) - - def inverted(self): - return MercatorLatitudeScale.MercatorLatitudeTransform(self.thresh) - -Now we're back to methods for the ``MercatorLatitudeScale`` class. -Any keyword arguments passed to ``set_xscale`` and ``set_yscale`` will -be passed along to the scale's constructor. In the case of -``MercatorLatitudeScale``, the ``thresh`` keyword argument specifies -the degree at which to crop the plot data. The constructor also -creates a local instance of the ``Transform`` class defined above, -which is made available through its ``get_transform`` method:: - - def __init__(self, axis, **kwargs): - thresh = kwargs.pop("thresh", (85 / 180.0) * npy.pi) - if thresh >= npy.pi / 2.0: - raise ValueError("thresh must be less than pi/2") - self.thresh = thresh - self._transform = self.MercatorLatitudeTransform(thresh) - - def get_transform(self): - return self._transform - -The ``limit_range_for_scale`` method must be provided to limit the -bounds of the axis to the domain of the function. In the case of -Mercator, the bounds should be limited to the threshold that was -passed in. Unlike the autoscaling provided by the tick locators, this -range limiting will always be adhered to, whether the axis range is set -manually, determined automatically or changed through panning and -zooming:: - - def limit_range_for_scale(self, vmin, vmax, minpos): - return max(vmin, -self.thresh), min(vmax, self.thresh) - -Lastly, the ``set_default_locators_and_formatters`` method sets up the -locators and formatters to use with the scale. It may be that the new -scale requires new locators and formatters. Doing so is outside the -scope of this document, but there are many examples in ``ticker.py``. -The Mercator example uses a fixed locator from -90 to 90 degrees and a -custom formatter class to put convert the radians to degrees and put a -degree symbol after the value:: - - def set_default_locators_and_formatters(self, axis): - class DegreeFormatter(Formatter): - def __call__(self, x, pos=None): - # \u00b0 : degree symbol - return u"%d\u00b0" % ((x / npy.pi) * 180.0) - - deg2rad = npy.pi / 180.0 - axis.set_major_locator(FixedLocator( - npy.arange(-90, 90, 10) * deg2rad)) - axis.set_major_formatter(DegreeFormatter()) - axis.set_minor_formatter(DegreeFormatter()) - -Now that the Scale class has been defined, it must be registered so -that ``matplotlib`` can find it:: - - register_scale(MercatorLatitudeScale) - -.. _Mercator: http://en.wikipedia.org/wiki/Mercator_projection \ No newline at end of file +A full-fledged and heavily annotated example is in +``examples/custom_projection_example.py``. The polar plot +functionality in ``polar.py`` may also be interest. Added: branches/transforms/examples/custom_projection_example.py =================================================================== --- branches/transforms/examples/custom_projection_example.py (rev 0) +++ branches/transforms/examples/custom_projection_example.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -0,0 +1,473 @@ +from matplotlib.axes import Axes +from matplotlib import cbook +from matplotlib.patches import Circle +from matplotlib.path import Path +from matplotlib.ticker import Formatter, Locator, NullLocator, FixedLocator, NullFormatter +from matplotlib.transforms import Affine2D, Affine2DBase, Bbox, \ + BboxTransformTo, IdentityTransform, Transform, TransformWrapper +from matplotlib.projections import register_projection + +# This example projection class is rather long, but it is designed to +# illustrate many features, not all of which will be used every time. +# It is also common to factor out a lot of these methods into common +# code used by a number of projections with similar characteristics +# (see geo.py). + +class HammerAxes(Axes): + """ + A custom class for the Aitoff-Hammer projection, an equal-area map + projection. + + http://en.wikipedia.org/wiki/Hammer_projection + """ + # The projection must specify a name. This will be used be the + # user to select the projection, i.e. ``subplot(111, + # projection='hammer')``. + name = 'hammer' + + # The number of interpolation steps when converting from straight + # lines to curves. (See ``transform_path``). + RESOLUTION = 75 + + def __init__(self, *args, **kwargs): + Axes.__init__(self, *args, **kwargs) + self.set_aspect(0.5, adjustable='box', anchor='C') + self.cla() + + def cla(self): + """ + Override to set up some reasonable defaults. + """ + # Don't forget to call the base class + Axes.cla(self) + + # Set up a default grid spacing + self.set_longitude_grid(30) + self.set_latitude_grid(15) + self.set_longitude_grid_ends(75) + + # Turn off minor ticking altogether + self.xaxis.set_minor_locator(NullLocator()) + self.yaxis.set_minor_locator(NullLocator()) + + # Do not display ticks -- we only want gridlines and text + self.xaxis.set_ticks_position('none') + self.yaxis.set_ticks_position('none') + + # The limits on this projection are fixed -- they are not to + # be changed by the user. This makes the math in the + # transformation itself easier, and since this is a toy + # example, the easier, the better. + Axes.set_xlim(self, -npy.pi, npy.pi) + Axes.set_ylim(self, -npy.pi / 2.0, npy.pi / 2.0) + + def cla(self): + """ + Initialize the Axes object to reasonable defaults. + """ + Axes.cla(self) + + self.set_longitude_grid(30) + self.set_latitude_grid(15) + self.set_longitude_grid_ends(75) + self.xaxis.set_minor_locator(NullLocator()) + self.yaxis.set_minor_locator(NullLocator()) + self.xaxis.set_ticks_position('none') + self.yaxis.set_ticks_position('none') + + # self.grid(rcParams['axes.grid']) + + Axes.set_xlim(self, -npy.pi, npy.pi) + Axes.set_ylim(self, -npy.pi / 2.0, npy.pi / 2.0) + + def _set_lim_and_transforms(self): + """ + This is called once when the plot is created to set up all the + transforms for the data, text and grids. + """ + # There are three important coordinate spaces going on here: + # + # 1. Data space: The space of the data itself + # + # 2. Axes space: The unit rectangle (0, 0) to (1, 1) + # covering the entire plot area. + # + # 3. Display space: The coordinates of the resulting image, + # often in pixels or dpi/inch. + + # This function makes heavy use of the Transform classes in + # ``lib/matplotlib/transforms.py.`` For more information, see + # the inline documentation there. + + # The goal of the first two transformations is to get from the + # data space (in this case longitude and latitude) to axes + # space. It is separated into a non-affine and affine part so + # that the non-affine part does not have to be recomputed when + # a simple affine change to the figure has been made (such as + # resizing the window or changing the dpi). + + # 1) The core transformation from data space into + # rectilinear space defined in the HammerTransform class. + self.transProjection = self.HammerTransform(self.RESOLUTION) + + # 2) The above has an output range that is not in the unit + # rectangle, so scale and translate it so it fits correctly + # within the axes. The peculiar calculations of xscale and + # yscale are specific to a Aitoff-Hammer projection, so don't + # worry about them too much. + xscale = 2.0 * npy.sqrt(2.0) * npy.sin(0.5 * npy.pi) + yscale = npy.sqrt(2.0) * npy.sin(0.5 * npy.pi) + self.transAffine = Affine2D() \ + .scale(0.5 / xscale, 0.5 / yscale) \ + .translate(0.5, 0.5) + + # 3) This is the transformation from axes space to display + # space. + self.transAxes = BboxTransformTo(self.bbox) + + # Now put these 3 transforms together -- from data all the way + # to display coordinates. Using the '+' operator, these + # transforms will be applied "in order". The transforms are + # automatically simplified, if possible, by the underlying + # transformation framework. + self.transData = \ + self.transProjection + \ + self.transAffine + \ + self.transAxes + + # The main data transformation is set up. Now deal with + # gridlines and tick labels. + + # Longitude gridlines and ticklabels. The input to these + # transforms are in display space in x and axes space in y. + # Therefore, the input values will be in range (-xmin, 0), + # (xmax, 1). The goal of these transforms is to go from that + # space to display space. The tick labels will be offset 4 + # pixels from the equator. + self._xaxis_pretransform = \ + Affine2D() \ + .scale(1.0, npy.pi) \ + .translate(0.0, -npy.pi) + self._xaxis_transform = \ + self._xaxis_pretransform + \ + self.transData + self._xaxis_text1_transform = \ + Affine2D().scale(1.0, 0.0) + \ + self.transData + \ + Affine2D().translate(0.0, 4.0) + self._xaxis_text2_transform = \ + Affine2D().scale(1.0, 0.0) + \ + self.transData + \ + Affine2D().translate(0.0, -4.0) + + # Now set up the transforms for the latitude ticks. The input to + # these transforms are in axes space in x and display space in + # y. Therefore, the input values will be in range (0, -ymin), + # (1, ymax). The goal of these transforms is to go from that + # space to display space. The tick labels will be offset 4 + # pixels from the edge of the axes ellipse. + yaxis_stretch = Affine2D().scale(npy.pi * 2.0, 1.0).translate(-npy.pi, 0.0) + yaxis_space = Affine2D().scale(1.0, 1.1) + self._yaxis_transform = \ + yaxis_stretch + \ + self.transData + yaxis_text_base = \ + yaxis_stretch + \ + self.transProjection + \ + (yaxis_space + \ + self.transAffine + \ + self.transAxes) + self._yaxis_text1_transform = \ + yaxis_text_base + \ + Affine2D().translate(-8.0, 0.0) + self._yaxis_text2_transform = \ + yaxis_text_base + \ + Affine2D().translate(8.0, 0.0) + + def get_xaxis_transform(self): + """ + Override this method to provide a transformation for the + x-axis grid and ticks. + """ + return self._xaxis_transform + + def get_xaxis_text1_transform(self, pixelPad): + """ + Override this method to provide a transformation for the + x-axis tick labels. + + Returns a tuple of the form (transform, valign, halign) + """ + return self._xaxis_text1_transform, 'bottom', 'center' + + def get_xaxis_text2_transform(self, pixelPad): + """ + Override this method to provide a transformation for the + secondary x-axis tick labels. + + Returns a tuple of the form (transform, valign, halign) + """ + return self._xaxis_text2_transform, 'top', 'center' + + def get_yaxis_transform(self): + """ + Override this method to provide a transformation for the + y-axis grid and ticks. + """ + return self._yaxis_transform + + def get_yaxis_text1_transform(self, pixelPad): + """ + Override this method to provide a transformation for the + y-axis tick labels. + + Returns a tuple of the form (transform, valign, halign) + """ + return self._yaxis_text1_transform, 'center', 'right' + + def get_yaxis_text2_transform(self, pixelPad): + """ + Override this method to provide a transformation for the + secondary y-axis tick labels. + + Returns a tuple of the form (transform, valign, halign) + """ + return self._yaxis_text2_transform, 'center', 'left' + + def get_axes_patch(self): + """ + Override this method to define the shape that is used for the + background of the plot. It should be a subclass of Patch. + + In this case, it is a Circle (that may be warped by the axes + transform into an ellipse). Any data and gridlines will be + clipped to this shape. + """ + return Circle((0.5, 0.5), 0.5) + + # Prevent the user from applying scales to one or both of the + # axes. In this particular case, scaling the axes wouldn't make + # sense, so we don't allow it. + def set_xscale(self, *args, **kwargs): + if args[0] != 'linear': + raise NotImplementedError + Axes.set_xscale(self, *args, **kwargs) + + def set_yscale(self, *args, **kwargs): + if args[0] != 'linear': + raise NotImplementedError + Axes.set_yscale(self, *args, **kwargs) + + # Prevent the user from changing the axes limits. In our case, we + # want to display the whole sphere all the time, so we override + # set_xlim and set_ylim to ignore any input. This also applies to + # interactive panning and zooming in the GUI interfaces. + def set_xlim(self, *args, **kwargs): + Axes.set_xlim(self, -npy.pi, npy.pi) + Axes.set_ylim(self, -npy.pi / 2.0, npy.pi / 2.0) + set_ylim = set_xlim + + def format_coord(self, long, lat): + """ + Override this method to change how the values are displayed in + the status bar. + + In this case, we want them to be displayed in degrees N/S/E/W. + """ + long = long * (180.0 / npy.pi) + lat = lat * (180.0 / npy.pi) + if lat >= 0.0: + ns = 'N' + else: + ns = 'S' + if long >= 0.0: + ew = 'E' + else: + ew = 'W' + # \u00b0 : degree symbol + return u'%f\u00b0%s, %f\u00b0%s' % (abs(lat), ns, abs(long), ew) + + class DegreeFormatter(Formatter): + """ + This is a custom formatter that converts the native unit of + radians into (truncated) degrees and adds a degree symbol. + """ + def __init__(self, round_to=1.0): + self._round_to = round_to + + def __call__(self, x, pos=None): + degrees = (x / npy.pi) * 180.0 + degrees = round(degrees / self._round_to) * self._round_to + # \u00b0 : degree symbol + return u"%d\u00b0" % degrees + + def set_longitude_grid(self, degrees): + """ + Set the number of degrees between each longitude grid. + + This is an example method that is specific to this projection + class -- it provides a more convenient interface to set the + ticking than set_xticks would. + """ + # Set up a FixedLocator at each of the points, evenly spaced + # by degrees. + number = (360.0 / degrees) + 1 + self.xaxis.set_major_locator( + FixedLocator( + npy.linspace(-npy.pi, npy.pi, number, True)[1:-1])) + # Set the formatter to display the tick labels in degrees, + # rather than radians. + self.xaxis.set_major_formatter(self.DegreeFormatter(degrees)) + + def set_latitude_grid(self, degrees): + """ + Set the number of degrees between each longitude grid. + + This is an example method that is specific to this projection + class -- it provides a more convenient interface than + set_yticks would. + """ + # Set up a FixedLocator at each of the points, evenly spaced + # by degrees. + number = (180.0 / degrees) + 1 + self.yaxis.set_major_locator( + FixedLocator( + npy.linspace(-npy.pi / 2.0, npy.pi / 2.0, number, True)[1:-1])) + # Set the formatter to display the tick labels in degrees, + # rather than radians. + self.yaxis.set_major_formatter(self.DegreeFormatter(degrees)) + + def set_longitude_grid_ends(self, degrees): + """ + Set the latitude(s) at which to stop drawing the longitude grids. + + Often, in geographic projections, you wouldn't want to draw + longitude gridlines near the poles. This allows the user to + specify the degree at which to stop drawing longitude grids. + + This is an example method that is specific to this projection + class -- it provides an interface to something that has no + analogy in the base Axes class. + """ + longitude_cap = degrees * (npy.pi / 180.0) + # Change the xaxis gridlines transform so that it draws from + # -degrees to degrees, rather than -pi to pi. + self._xaxis_pretransform \ + .clear() \ + .scale(1.0, longitude_cap * 2.0) \ + .translate(0.0, -longitude_cap) + + def get_data_ratio(self): + """ + Return the aspect ratio of the data itself. + + This method should be overridden by any Axes that have a + fixed data ratio. + """ + return 1.0 + + # Interactive panning and zooming is not supported with this projection, + # so we override all of the following methods to disable it. + def can_zoom(self): + """ + Return True if this axes support the zoom box + """ + return False + def start_pan(self, x, y, button): + pass + def end_pan(self): + pass + def drag_pan(self, button, key, x, y): + pass + + # Now, the transforms themselves. + + class HammerTransform(Transform): + """ + The base Hammer transform. + """ + input_dims = 2 + output_dims = 2 + is_separable = False + + def __init__(self, resolution): + """ + Create a new Hammer transform. Resolution is the number of steps + to interpolate between each input line segment to approximate its + path in curved Hammer space. + """ + Transform.__init__(self) + self._resolution = resolution + + def transform(self, ll): + """ + Override the transform method to implement the custom transform. + + The input and output are Nx2 numpy arrays. + """ + longitude = ll[:, 0:1] + latitude = ll[:, 1:2] + + # Pre-compute some values + half_long = longitude / 2.0 + cos_latitude = npy.cos(latitude) + sqrt2 = npy.sqrt(2.0) + + alpha = 1.0 + cos_latitude * npy.cos(half_long) + x = (2.0 * sqrt2) * (cos_latitude * npy.sin(half_long)) / alpha + y = (sqrt2 * npy.sin(latitude)) / alpha + return npy.concatenate((x, y), 1) + + # This is where things get interesting. With this projection, + # straight lines in data space become curves in display space. + # This is done by interpolating new values between the input + # values of the data. Since ``transform`` must not return a + # differently-sized array, any transform that requires + # changing the length of the data array must happen within + # ``transform_path``. + def transform_path(self, path): + vertices = path.vertices + ipath = path.interpolated(self._resolution) + return Path(self.transform(ipath.vertices), ipath.codes) + + def inverted(self): + return HammerAxes.InvertedHammerTransform(self._resolution) + inverted.__doc__ = Transform.inverted.__doc__ + + class InvertedHammerTransform(Transform): + input_dims = 2 + output_dims = 2 + is_separable = False + + def __init__(self, resolution): + Transform.__init__(self) + self._resolution = resolution + + def transform(self, xy): + x = xy[:, 0:1] + y = xy[:, 1:2] + + quarter_x = 0.25 * x + half_y = 0.5 * y + z = npy.sqrt(1.0 - quarter_x*quarter_x - half_y*half_y) + longitude = 2 * npy.arctan((z*x) / (2.0 * (2.0*z*z - 1.0))) + latitude = npy.arcsin(y*z) + return npy.concatenate((longitude, latitude), 1) + transform.__doc__ = Transform.transform.__doc__ + + def inverted(self): + # The inverse of the inverse is the original transform... ;) + return HammerAxes.HammerTransform(self._resolution) + inverted.__doc__ = Transform.inverted.__doc__ + +# Now register the projection with matplotlib so the user can select +# it. +register_projection(HammerAxes) + +# Now make a simple example using the custom projection. +from pylab import * + +subplot(111, projection="hammer") +grid(True) + +show() Added: branches/transforms/examples/custom_scale_example.py =================================================================== --- branches/transforms/examples/custom_scale_example.py (rev 0) +++ branches/transforms/examples/custom_scale_example.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -0,0 +1,165 @@ +from matplotlib import scale as mscale +from matplotlib import transforms as mtransforms + +class MercatorLatitudeScale(mscale.ScaleBase): + """ + Scales data in range -pi/2 to pi/2 (-90 to 90 degrees) using + the system used to scale latitudes in a Mercator projection. + + The scale function: + ln(tan(y) + sec(y)) + + The inverse scale function: + atan(sinh(y)) + + Since the Mercator scale tends to infinity at +/- 90 degrees, + there is user-defined threshold, above and below which nothing + will be plotted. This defaults to +/- 85 degrees. + + source: + http://en.wikipedia.org/wiki/Mercator_projection + """ + + # The scale class must have a member ``name`` that defines the + # string used to select the scale. For example, + # ``gca().set_yscale("mercator")`` would be used to select this + # scale. + name = 'mercator' + + + def __init__(self, axis, **kwargs): + """ + Any keyword arguments passed to ``set_xscale`` and + ``set_yscale`` will be passed along to the scale's + constructor. + + thresh: The degree above which to crop the data. + """ + mscale.ScaleBase.__init__(self) + thresh = kwargs.pop("thresh", (85 / 180.0) * npy.pi) + if thresh >= npy.pi / 2.0: + raise ValueError("thresh must be less than pi/2") + self.thresh = thresh + + def get_transform(self): + """ + Override this method to return a new instance that does the + actual transformation of the data. + + The MercatorLatitudeTransform class is defined below as a + nested class of this one. + """ + return self.MercatorLatitudeTransform(self.thresh) + + def set_default_locators_and_formatters(self, axis): + """ + Override to set up the locators and formatters to use with the + scale. This is only required if the scale requires custom + locators and formatters. Writing custom locators and + formatters is rather outside the scope of this example, but + there are many helpful examples in ``ticker.py``. + + In our case, the Mercator example uses a fixed locator from + -90 to 90 degrees and a custom formatter class to put convert + the radians to degrees and put a degree symbol after the + value:: + """ + class DegreeFormatter(Formatter): + def __call__(self, x, pos=None): + # \u00b0 : degree symbol + return u"%d\u00b0" % ((x / npy.pi) * 180.0) + + deg2rad = npy.pi / 180.0 + axis.set_major_locator(FixedLocator( + npy.arange(-90, 90, 10) * deg2rad)) + axis.set_major_formatter(DegreeFormatter()) + axis.set_minor_formatter(DegreeFormatter()) + + def limit_range_for_scale(self, vmin, vmax, minpos): + """ + Override to limit the bounds of the axis to the domain of the + transform. In the case of Mercator, the bounds should be + limited to the threshold that was passed in. Unlike the + autoscaling provided by the tick locators, this range limiting + will always be adhered to, whether the axis range is set + manually, determined automatically or changed through panning + and zooming. + """ + return max(vmin, -self.thresh), min(vmax, self.thresh) + + class MercatorLatitudeTransform(mtransforms.Transform): + # There are two value members that must be defined. + # ``input_dims`` and ``output_dims`` specify number of input + # dimensions and output dimensions to the transformation. + # These are used by the transformation framework to do some + # error checking and prevent incompatible transformations from + # being connected together. When defining transforms for a + # scale, which are, by definition, separable and have only one + # dimension, these members should always be set to 1. + input_dims = 1 + output_dims = 1 + is_separable = True + + def __init__(self, thresh): + mtransforms.Transform.__init__(self) + self.thresh = thresh + + def transform(self, a): + """ + This transform takes an Nx1 ``numpy`` array and returns a + transformed copy. Since the range of the Mercator scale + is limited by the user-specified threshold, the input + array must be masked to contain only valid values. + ``matplotlib`` will handle masked arrays and remove the + out-of-range data from the plot. Importantly, the + ``transform`` method *must* return an array that is the + same shape as the input array, since these values need to + remain synchronized with values in the other dimension. + """ + masked = ma.masked_where((a < -self.thresh) | (a > self.thresh), a) + if masked.mask.any(): + return ma.log(npy.abs(ma.tan(masked) + 1.0 / ma.cos(masked))) + else: + return npy.log(npy.abs(npy.tan(a) + 1.0 / npy.cos(a))) + + def inverted(self): + """ + Override this method so matplotlib knows how to get the + inverse transform for this transform. + """ + return MercatorLatitudeScale.InvertedMercatorLatitudeTransform(self.thresh) + + class InvertedMercatorLatitudeTransform(mtransforms.Transform): + input_dims = 1 + output_dims = 1 + is_separable = True + + def __init__(self, thresh): + mtransforms.Transform.__init__(self) + self.thresh = thresh + + def transform(self, a): + return npy.arctan(npy.sinh(a)) + + def inverted(self): + return MercatorLatitudeScale.MercatorLatitudeTransform(self.thresh) + +# Now that the Scale class has been defined, it must be registered so +# that ``matplotlib`` can find it. +mscale.register_scale(MercatorLatitudeScale) + +from pylab import * +import numpy as npy + +t = arange(-180.0, 180.0, 0.1) +s = t / 360.0 * npy.pi + +plot(t, s, '-', lw=2) +gca().set_yscale('mercator') + +xlabel('Longitude') +ylabel('Latitude') +title('Mercator: Projection of the Oppressor') +grid(True) + +show() Modified: branches/transforms/lib/matplotlib/axes.py =================================================================== --- branches/transforms/lib/matplotlib/axes.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/axes.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -550,6 +550,10 @@ self.bbox = mtransforms.TransformedBbox(self._position, fig.transFigure) #these will be updated later as data is added + self.dataLim = mtransforms.Bbox.unit() + self.viewLim = mtransforms.Bbox.unit() + self.transScale = mtransforms.TransformWrapper(mtransforms.IdentityTransform()) + self._set_lim_and_transforms() def _set_lim_and_transforms(self): @@ -558,8 +562,6 @@ transScale, transData, transLimits and transAxes transformations. """ - self.dataLim = mtransforms.Bbox.unit() - self.viewLim = mtransforms.Bbox.unit() self.transAxes = mtransforms.BboxTransformTo(self.bbox) # Transforms the x and y axis separately by a scale factor Modified: branches/transforms/lib/matplotlib/patches.py =================================================================== --- branches/transforms/lib/matplotlib/patches.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/patches.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -371,6 +371,7 @@ self._width = width self._height = height self._rect_transform = transforms.IdentityTransform() + self._update_patch_transform() __init__.__doc__ = cbook.dedent(__init__.__doc__) % artist.kwdocd def get_path(self): @@ -862,6 +863,7 @@ self.angle = angle self._path = Path.unit_circle() self._patch_transform = transforms.IdentityTransform() + self._recompute_transform() def _recompute_transform(self): center = (self.convert_xunits(self.center[0]), Modified: branches/transforms/lib/matplotlib/projections/__init__.py =================================================================== --- branches/transforms/lib/matplotlib/projections/__init__.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/projections/__init__.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -26,7 +26,11 @@ AitoffAxes, HammerAxes, LambertAxes) +) +def register_projection(cls): + projection_registry.register(cls) + def get_projection_class(projection): if projection is None: projection = 'rectilinear' Modified: branches/transforms/lib/matplotlib/projections/geo.py =================================================================== --- branches/transforms/lib/matplotlib/projections/geo.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/projections/geo.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -51,19 +51,13 @@ Axes.set_ylim(self, -npy.pi / 2.0, npy.pi / 2.0) def _set_lim_and_transforms(self): - self.dataLim = Bbox.unit() - self.viewLim = Bbox.unit() - self.transAxes = BboxTransformTo(self.bbox) - - # Transforms the x and y axis separately by a scale factor - # It is assumed that this part will have non-linear components - self.transScale = TransformWrapper(IdentityTransform()) - # A (possibly non-linear) projection on the (already scaled) data self.transProjection = self._get_core_transform(self.RESOLUTION) self.transAffine = self._get_affine_transform() + self.transAxes = BboxTransformTo(self.bbox) + # The complete data transformation stack -- from data all the # way to display coordinates self.transData = \ Modified: branches/transforms/lib/matplotlib/projections/polar.py =================================================================== --- branches/transforms/lib/matplotlib/projections/polar.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/projections/polar.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -186,8 +186,6 @@ self.yaxis.set_ticks_position('none') def _set_lim_and_transforms(self): - self.dataLim = Bbox.unit() - self.viewLim = Bbox.unit() self.transAxes = BboxTransformTo(self.bbox) # Transforms the x and y axis separately by a scale factor Modified: branches/transforms/lib/matplotlib/scale.py =================================================================== --- branches/transforms/lib/matplotlib/scale.py 2008-01-06 18:28:17 UTC (rev 4802) +++ branches/transforms/lib/matplotlib/scale.py 2008-01-07 21:15:58 UTC (rev 4803) @@ -307,94 +307,11 @@ return self._transform -class MercatorLatitudeScale(ScaleBase): - """ - Scales data in range -pi/2 to pi/2 (-90 to 90 degrees) using - the system used to scale latitudes in a Mercator projection. - The scale function: - ln(tan(y) + sec(y)) - - The inverse scale function: - atan(sinh(y)) - - Since the Mercator scale tends to infinity at +/- 90 degrees, - there is user-defined threshold, above and below which nothing - will be plotted. This defaults to +/- 85 degrees. - - source: - http://en.wikipedia.org/wiki/Mercator_projection - """ - name = 'mercator_latitude' - - class MercatorLatitudeTransform(Transform): - input_dims = 1 - output_dims = 1 - is_separable = True - - def __init__(self, thresh): - Transform.__init__(self) - self.thresh = thresh - - def transform(self, a): - masked = ma.masked_where((a < -self.thresh) | (a > self.thresh), a) - if masked.mask.any(): - return ma.log(npy.abs(ma.tan(masked) + 1.0 / ma.cos(masked))) - else: - return npy.log(npy.abs(npy.tan(a) + 1.0 / npy.cos(a))) - - def inverted(self): - return MercatorLatitudeScale.InvertedMercatorLatitudeTransform(self.thresh) - - class InvertedMercatorLatitudeTransform(Transform): - input_dims = 1 - output_dims = 1 - is_separable = True - - def __init__(self, thresh): - Transform.__init__(self) - self.thresh = thresh - - def transform(self, a): - return npy.arctan(npy.sinh(a)) - - def inverted(self): - return MercatorLatitudeScale.MercatorLatitudeTransform(self.thresh) - - def __init__(self, axis, **kwargs): - """ - thresh: The degree above which to crop the data. - """ - thresh = kwargs.pop("thresh", (85 / 180.0) * npy.pi) - if thresh >= npy.pi / 2.0: - raise ValueError("thresh must be less than pi/2") - self.thresh = thresh - self._transform = self.MercatorLatitudeTransform(thresh) - - def set_default_locators_and_formatters(self, axis): - class DegreeFormatter(Formatter): - def __call__(self, x, pos=None): - # \u00b0 : degree symbol - return u"%d\u00b0" % ((x / npy.pi) * 180.0) - - deg2rad = npy.pi / 180.0 - axis.set_major_locator(FixedLocator( - npy.arange(-90, 90, 10) * deg2rad)) - axis.set_major_formatter(DegreeFormatter()) - axis.set_minor_formatter(DegreeFormatter()) - - def get_transform(self): - return self._transform - - def limit_range_for_scale(self, vmin, vmax, minpos): - return max(vmin, -self.thresh), min(vmax, self.thresh) - - _scale_mapping = { 'linear' : LinearScale, 'log' : LogScale, - 'symlog' : SymmetricalLogScale, - 'mercator_latitude' : MercatorLatitudeScale + 'symlog' : SymmetricalLogScale } def scale_factory(scale, axis, **kwargs): scale = scale.lower() This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site. |