diff --git a/lib/cartopy/mpl/ticker.py b/lib/cartopy/mpl/ticker.py new file mode 100644 index 0000000000..f12bd3c1b6 --- /dev/null +++ b/lib/cartopy/mpl/ticker.py @@ -0,0 +1,190 @@ +# (C) British Crown Copyright 2014, Met Office +# +# This file is part of cartopy. +# +# cartopy is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cartopy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with cartopy. If not, see . +"""This module contains tools for handling tick marks cartopy.""" +from matplotlib.ticker import Formatter + +import cartopy.crs as ccrs +from cartopy.mpl.geoaxes import GeoAxes + + +class _GeoFormatter(Formatter): + """Base class for formatting ticks on geographical axes.""" + + def __init__(self, degree_symbol=u'\u00B0', number_format='g'): + """ + Create a formatter for geographical axis values. + + Kwargs: + + * degree_symbol (string): + The character(s) used to represent the degree symbol in the + tick labels. Defaults to u'\u00B0' which is the unicode + degree symbol. Can be an empty string if no degree symbol is + desired. + + * number_format (string): + Format string to represent the tick values. Defaults to 'g'. + + """ + self._degree_symbol = degree_symbol + self._number_format = number_format + + def __call__(self, value, pos=None): + if not isinstance(self.axis.axes, GeoAxes): + raise TypeError("This formatter can only be " + "used with cartopy axes.") + # We want to produce labels for values in the familiar Plate Carree + # projection, so convert the tick values from their own projection + # before formatting them. + source = self.axis.axes.projection + if not isinstance(source, (ccrs._RectangularProjection, + ccrs.Mercator)): + raise TypeError("This formatter cannot be used with " + "non-rectangular projections.") + target = ccrs.PlateCarree() + projected_value = self.extract_transform_result( + target.transform_point(*self.make_transform_args(value, source))) + # Round the transformed values to the nearest 0.1 degree for display + # purposes (transforms can introduce minor rounding errors that make + # the tick values look bad). + projected_value = round(10 * projected_value) / 10 + # Return the formatted values, the formatter has both the re-projected + # tick value and the original axis value available to it. + return self._format_value(projected_value, value) + + def _format_value(self, value, original_value): + hemisphere = self.hemisphere(value, original_value) + fmt_string = u'{value:{number_format}}{degree}{hemisphere}' + return fmt_string.format(value=abs(value), + number_format=self._number_format, + degree=self._degree_symbol, + hemisphere=hemisphere) + + def make_transform_args(self, value, source_crs): + """ + Given a single coordinate value and a source `CRS` returns a + 3-tuple of arguments suitable for use by `CRS.transform_point`. + + Must be over-ridden by the derived class. + + """ + raise NotImplementedError("A subclass must implement this method.") + + def extract_transform_result(self, transform_result): + """ + Given a 2-tuple returned from `CRS.transform_point` returns the + required element. + + Must be over-ridden by the derived class. + + """ + raise NotImplementedError("A subclass must implement this method.") + + def hemisphere(self, value, value_source_crs): + """ + Given both a tick value in the Plate Carree projection and the + same value in the source CRS returns a string indicating the + hemisphere that the value is in. + + Must be over-ridden by the derived class. + + """ + raise NotImplementedError("A subclass must implement this method.") + + +class LatitudeFormatter(_GeoFormatter): + """Tick formatter for latitude axes.""" + + def make_transform_args(self, value, source_crs): + return (0, value, source_crs) + + def extract_transform_result(self, transform_result): + return transform_result[1] + + def hemisphere(self, value, value_source_crs): + if value > 0: + hemisphere = 'N' + elif value < 0: + hemisphere = 'S' + else: + hemisphere = '' + return hemisphere + + +class LongitudeFormatter(_GeoFormatter): + """Tick formatter for longitude axes.""" + + def __init__(self, + zero_direction_label=False, + dateline_direction_label=False, + degree_symbol=u'\u00B0', + number_format='g'): + """ + Create a formatter for longitude values. + + Kwargs: + + * zero_direction_label (False | True): + If *True* a direction label (E or W) will be drawn next to + longitude labels with the value 0. If *False* then these + labels will not be drawn. Defaults to *False* (no direction + labels). + + * dateline_direction_label (False | True): + If *True* a direction label (E or W) will be drawn next to + longitude labels with the value 180. If *False* then these + labels will not be drawn. Defaults to *False* (no direction + labels). + + * degree_symbol (string): + The symbol used to represent degrees. Defaults to u'\u00B0' + which is the unicode degree symbol. + + * number_format (string): + Format string to represent the longitude values. Defaults to + 'g'. + + """ + super(LongitudeFormatter, self).__init__(degree_symbol=degree_symbol, + number_format=number_format) + self._zero_direction_labels = zero_direction_label + self._dateline_direction_labels = dateline_direction_label + + def make_transform_args(self, value, source_crs): + return (value, 0, source_crs) + + def extract_transform_result(self, transform_result): + return transform_result[0] + + def hemisphere(self, value, value_source_crs): + # Perform basic hemisphere detection. + if value < 0: + hemisphere = 'W' + elif value > 0: + hemisphere = 'E' + else: + hemisphere = '' + # Correct for user preferences: + if value == 0 and self._zero_direction_labels: + # Use the original tick value to determine the hemisphere. + if value_source_crs < 0: + hemisphere = 'E' + else: + hemisphere = 'W' + if value in (-180, 180) and not self._dateline_direction_labels: + hemisphere = '' + return hemisphere diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_0.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_0.png new file mode 100644 index 0000000000..59f7dea3fe Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_0.png differ diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_120.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_120.png new file mode 100644 index 0000000000..f0c9259b16 Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_120.png differ diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_180.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_180.png new file mode 100644 index 0000000000..167ec433ec Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_central_longitude_180.png differ diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_mercator.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_mercator.png new file mode 100644 index 0000000000..29678acb58 Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_ticker/ticks_mercator.png differ diff --git a/lib/cartopy/tests/mpl/test_ticker.py b/lib/cartopy/tests/mpl/test_ticker.py new file mode 100644 index 0000000000..09701b0b41 --- /dev/null +++ b/lib/cartopy/tests/mpl/test_ticker.py @@ -0,0 +1,86 @@ +# (C) British Crown Copyright 2014, Met Office +# +# This file is part of cartopy. +# +# cartopy is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the +# Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cartopy is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with cartopy. If not, see . +from nose.tools import raises +import matplotlib.pyplot as plt + +import cartopy.crs as ccrs +from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter +from cartopy.tests.mpl import ImageTesting + + +def _run_test(projection, xticks, yticks, xformatter, yformatter): + ax = plt.axes(projection=projection) + ax.set_global() + ax.coastlines() + ax.set_xticks(xticks, crs=ccrs.PlateCarree()) + ax.set_yticks(yticks, crs=ccrs.PlateCarree()) + ax.xaxis.set_major_formatter(xformatter) + ax.yaxis.set_major_formatter(yformatter) + + +@ImageTesting(['ticks_central_longitude_0']) +def test_central_longitude_0(): + _run_test(ccrs.PlateCarree(central_longitude=0), + [-180, -120, -60, 0, 60, 120, 180], + [-90, -60, -30, 0, 30, 60, 90], + LongitudeFormatter(dateline_direction_label=True), + LatitudeFormatter()) + + +@ImageTesting(['ticks_central_longitude_180']) +def test_central_longitude_180(): + _run_test(ccrs.PlateCarree(central_longitude=180), + [0, 60, 120, 180, 240, 300, 360], + [-90, -60, -30, 0, 30, 60, 90], + LongitudeFormatter(zero_direction_label=True), + LatitudeFormatter()) + + +@ImageTesting(['ticks_central_longitude_120']) +def test_central_longitude_120(): + _run_test(ccrs.PlateCarree(central_longitude=120), + [-60, 0, 60, 120, 180, 240, 300], + [-90, -60, -30, 0, 30, 60, 90], + LongitudeFormatter(degree_symbol='', number_format='.2f'), + LatitudeFormatter(degree_symbol='', number_format='.2f')) + + +@ImageTesting(['ticks_mercator']) +def test_mercator(): + _run_test(ccrs.Mercator(), + [-180, -120, -60, 0, 60, 120, 180], + [-80, -60, -30, 0, 30, 60, 80], + LongitudeFormatter(dateline_direction_label=True), + LatitudeFormatter()) + + +@raises(TypeError) +def test__GeoFormatter_invalid_axes(): + ax = plt.axes() + #try: + ax.xaxis.set_major_formatter(LongitudeFormatter) + #finally: + # plt.close() + + +@raises(TypeError) +def test__GeoFormatter_invalid_projection(): + ax = plt.axes(projection=ccrs.Stereographic()) + #try: + ax.xaxis.set_major_formatter(LongitudeFormatter) + #finally: + # plt.close()