Former-commit-id:a02aeb236c
[formerly9f19e3f712
] [formerlya02aeb236c
[formerly9f19e3f712
] [formerly06a8b51d6d
[formerly 64fa9254b946eae7e61bbc3f513b7c3696c4f54f]]] Former-commit-id:06a8b51d6d
Former-commit-id:8e80217e59
[formerly3360eb6c5f
] Former-commit-id:377dcd10b9
456 lines
17 KiB
Python
Executable file
456 lines
17 KiB
Python
Executable file
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
|
|
import matplotlib.spines as mspines
|
|
import matplotlib.axis as maxis
|
|
|
|
import numpy as np
|
|
|
|
# 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'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
Axes.__init__(self, *args, **kwargs)
|
|
self.set_aspect(0.5, adjustable='box', anchor='C')
|
|
self.cla()
|
|
|
|
def _init_axis(self):
|
|
self.xaxis = maxis.XAxis(self)
|
|
self.yaxis = maxis.YAxis(self)
|
|
# Do not register xaxis or yaxis with spines -- as done in
|
|
# Axes._init_axis() -- until HammerAxes.xaxis.cla() works.
|
|
# self.spines['hammer'].register_axis(self.yaxis)
|
|
self._update_transScale()
|
|
|
|
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, -np.pi, np.pi)
|
|
Axes.set_ylim(self, -np.pi / 2.0, np.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()
|
|
|
|
# 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 * np.sqrt(2.0) * np.sin(0.5 * np.pi)
|
|
yscale = np.sqrt(2.0) * np.sin(0.5 * np.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, np.pi) \
|
|
.translate(0.0, -np.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(np.pi * 2.0, 1.0).translate(-np.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,which='grid'):
|
|
"""
|
|
Override this method to provide a transformation for the
|
|
x-axis grid and ticks.
|
|
"""
|
|
assert which in ['tick1','tick2','grid']
|
|
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,which='grid'):
|
|
"""
|
|
Override this method to provide a transformation for the
|
|
y-axis grid and ticks.
|
|
"""
|
|
assert which in ['tick1','tick2','grid']
|
|
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 _gen_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)
|
|
|
|
def _gen_axes_spines(self):
|
|
return {'hammer':mspines.Spine.circular_spine(self,
|
|
(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, -np.pi, np.pi)
|
|
Axes.set_ylim(self, -np.pi / 2.0, np.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 / np.pi)
|
|
lat = lat * (180.0 / np.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 / np.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(
|
|
np.linspace(-np.pi, np.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(
|
|
np.linspace(-np.pi / 2.0, np.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 * (np.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 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 = np.cos(latitude)
|
|
sqrt2 = np.sqrt(2.0)
|
|
|
|
alpha = 1.0 + cos_latitude * np.cos(half_long)
|
|
x = (2.0 * sqrt2) * (cos_latitude * np.sin(half_long)) / alpha
|
|
y = (sqrt2 * np.sin(latitude)) / alpha
|
|
return np.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(path._interpolation_steps)
|
|
return Path(self.transform(ipath.vertices), ipath.codes)
|
|
|
|
def inverted(self):
|
|
return HammerAxes.InvertedHammerTransform()
|
|
inverted.__doc__ = Transform.inverted.__doc__
|
|
|
|
class InvertedHammerTransform(Transform):
|
|
input_dims = 2
|
|
output_dims = 2
|
|
is_separable = False
|
|
|
|
def transform(self, xy):
|
|
x = xy[:, 0:1]
|
|
y = xy[:, 1:2]
|
|
|
|
quarter_x = 0.25 * x
|
|
half_y = 0.5 * y
|
|
z = np.sqrt(1.0 - quarter_x*quarter_x - half_y*half_y)
|
|
longitude = 2 * np.arctan((z*x) / (2.0 * (2.0*z*z - 1.0)))
|
|
latitude = np.arcsin(y*z)
|
|
return np.concatenate((longitude, latitude), 1)
|
|
transform.__doc__ = Transform.transform.__doc__
|
|
|
|
def inverted(self):
|
|
# The inverse of the inverse is the original transform... ;)
|
|
return HammerAxes.HammerTransform()
|
|
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")
|
|
p = plot([-1, 1, 1], [-1, -1, 1], "o-")
|
|
grid(True)
|
|
|
|
show()
|