Repository: SciTools/cartopy

Issue:
Feature request: option to remove 'E', 'W', 'N', 'S' from axis labels
### Description
<!-- Please provide a general introduction to the issue/proposal. -->

I would like to be able to format my axis labels so that there is no N/S/E/W after the label. 

Is this possible? It seems like a simple option could be added to the Longitude/Latitude formatters. 

In Basemap there was an option for labelstyle="+/-" that could do exactly this. 
<!--
Feature request: option to remove 'E', 'W', 'N', 'S' from axis labels
### Description
<!-- Please provide a general introduction to the issue/proposal. -->

I would like to be able to format my axis labels so that there is no N/S/E/W after the label. 

Is this possible? It seems like a simple option could be added to the Longitude/Latitude formatters. 

In Basemap there was an option for labelstyle="+/-" that could do exactly this. 
<!--


Discussion:
Seems like an option for that could definitely be added to the formatters. We already have `dateline_direction_label` https://github.com/SciTools/cartopy/blob/466c901f8f38ab4a26fd2736ccf49c98086de03b/lib/cartopy/mpl/ticker.py#L318-L322
Maybe just `direction_label` as a boolean that defaults to True? Would you want to open a PR to add something like that in?
Hi,
I'm a beginner and I'd be happy to work on this.

I'm assuming the new `direction_label` option would overwrite both `zero_direction_label` and `dateline_direction_label`, right ?
Great, thanks for taking this on! I think this new keyword would take precedence over the more specific dateline options.
Seems like an option for that could definitely be added to the formatters. We already have `dateline_direction_label` https://github.com/SciTools/cartopy/blob/466c901f8f38ab4a26fd2736ccf49c98086de03b/lib/cartopy/mpl/ticker.py#L318-L322
Maybe just `direction_label` as a boolean that defaults to True? Would you want to open a PR to add something like that in?
Hi,
I'm a beginner and I'd be happy to work on this.

I'm assuming the new `direction_label` option would overwrite both `zero_direction_label` and `dateline_direction_label`, right ?
Great, thanks for taking this on! I think this new keyword would take precedence over the more specific dateline options.

Resolving patch:
diff --git a/lib/cartopy/mpl/ticker.py b/lib/cartopy/mpl/ticker.py
--- a/lib/cartopy/mpl/ticker.py
+++ b/lib/cartopy/mpl/ticker.py
@@ -21,8 +21,8 @@ class _PlateCarreeFormatter(Formatter):
 
     _target_projection = ccrs.PlateCarree()
 
-    def __init__(self, degree_symbol='\u00B0', number_format='g',
-                 transform_precision=1e-8, dms=False,
+    def __init__(self, direction_label=True, degree_symbol='\u00B0',
+                 number_format='g', transform_precision=1e-8, dms=False,
                  minute_symbol="'", second_symbol="''",
                  seconds_number_format='g',
                  auto_hide=True):
@@ -31,6 +31,7 @@ def __init__(self, degree_symbol='\u00B0', number_format='g',
         for latitude and longitude axes.
 
         """
+        self._direction_labels = direction_label
         self._degree_symbol = degree_symbol
         self._degrees_number_format = number_format
         self._transform_precision = transform_precision
@@ -74,10 +75,19 @@ def __call__(self, value, pos=None):
         return self._format_value(projected_value, value)
 
     def _format_value(self, value, original_value):
-        hemisphere = self._hemisphere(value, original_value)
+
+        hemisphere = ''
+        sign = ''
+
+        if self._direction_labels:
+            hemisphere = self._hemisphere(value, original_value)
+        else:
+            if (value != 0 and
+                    self._hemisphere(value, original_value) in ['W', 'S']):
+                sign = '-'
 
         if not self._dms:
-            return (self._format_degrees(abs(value)) +
+            return (sign + self._format_degrees(abs(value)) +
                     hemisphere)
 
         value, deg, mn, sec = self._get_dms(abs(value))
@@ -91,7 +101,7 @@ def _format_value(self, value, original_value):
             label = self._format_minutes(mn) + label
 
         if not self._auto_hide_degrees or not label:
-            label = self._format_degrees(deg) + hemisphere + label
+            label = sign + self._format_degrees(deg) + hemisphere + label
 
         return label
 
@@ -115,7 +125,7 @@ def _get_dms(self, x):
         degs = np.round(x, self._precision).astype('i')
         y = (x - degs) * 60
         mins = np.round(y, self._precision).astype('i')
-        secs = np.round((y - mins)*60, self._precision - 3)
+        secs = np.round((y - mins) * 60, self._precision - 3)
         return x, degs, mins, secs
 
     def set_locs(self, locs):
@@ -123,7 +133,7 @@ def set_locs(self, locs):
         if not self._auto_hide:
             return
         self.locs, degs, mins, secs = self._get_dms(self.locs)
-        secs = np.round(secs, self._precision-3).astype('i')
+        secs = np.round(secs, self._precision - 3).astype('i')
         secs0 = secs == 0
         mins0 = mins == 0
 
@@ -189,7 +199,9 @@ def _hemisphere(self, value, value_source_crs):
 
 class LatitudeFormatter(_PlateCarreeFormatter):
     """Tick formatter for latitude axes."""
-    def __init__(self, degree_symbol='\u00B0', number_format='g',
+
+    def __init__(self, direction_label=True,
+                 degree_symbol='\u00B0', number_format='g',
                  transform_precision=1e-8, dms=False,
                  minute_symbol="'", second_symbol="''",
                  seconds_number_format='g', auto_hide=True,
@@ -203,6 +215,11 @@ def __init__(self, degree_symbol='\u00B0', number_format='g',
 
         Parameters
         ----------
+        direction_label: optional
+            If *True* a direction label (N or S) will be drawn next to
+            latitude labels. If *False* then these
+            labels will not be drawn. Defaults to *True* (draw direction
+            labels).
         degree_symbol: optional
             The character(s) used to represent the degree symbol in the
             tick labels. Defaults to u'\u00B0' which is the unicode
@@ -264,6 +281,7 @@ def __init__(self, degree_symbol='\u00B0', number_format='g',
 
         """
         super().__init__(
+            direction_label=direction_label,
             degree_symbol=degree_symbol,
             number_format=number_format,
             transform_precision=transform_precision,
@@ -278,6 +296,7 @@ def _apply_transform(self, value, target_proj, source_crs):
         return target_proj.transform_point(0, value, source_crs)[1]
 
     def _hemisphere(self, value, value_source_crs):
+
         if value > 0:
             hemisphere = 'N'
         elif value < 0:
@@ -291,6 +310,7 @@ class LongitudeFormatter(_PlateCarreeFormatter):
     """Tick formatter for a longitude axis."""
 
     def __init__(self,
+                 direction_label=True,
                  zero_direction_label=False,
                  dateline_direction_label=False,
                  degree_symbol='\u00B0',
@@ -310,6 +330,11 @@ def __init__(self,
 
         Parameters
         ----------
+        direction_label: optional
+            If *True* a direction label (E or W) will be drawn next to
+            longitude labels. If *False* then these
+            labels will not be drawn. Defaults to *True* (draw direction
+            labels).
         zero_direction_label: optional
             If *True* a direction label (E or W) will be drawn next to
             longitude labels with the value 0. If *False* then these
@@ -380,6 +405,7 @@ def __init__(self,
             labels = [lon_formatter(value) for value in ticks]
         """
         super().__init__(
+            direction_label=direction_label,
             degree_symbol=degree_symbol,
             number_format=number_format,
             transform_precision=transform_precision,
@@ -422,6 +448,7 @@ def _format_degrees(self, deg):
         return _PlateCarreeFormatter._format_degrees(self, self._fix_lons(deg))
 
     def _hemisphere(self, value, value_source_crs):
+
         value = self._fix_lons(value)
         # Perform basic hemisphere detection.
         if value < 0:
@@ -476,7 +503,7 @@ def _guess_steps(self, vmin, vmax):
             steps = np.array([1, 1.5, 2, 2.5, 3, 5, 10])
 
         else:
-            steps = np.array([1, 10/6., 15/6., 20/6., 30/6., 10])
+            steps = np.array([1, 10 / 6., 15 / 6., 20 / 6., 30 / 6., 10])
 
         self.set_params(steps=np.array(steps))
 
@@ -498,6 +525,7 @@ class LatitudeLocator(LongitudeLocator):
     dms: bool
         Allow the locator to stop on minutes and seconds (False by default)
     """
+
     def tick_values(self, vmin, vmax):
         vmin = max(vmin, -90.)
         vmax = min(vmax, 90.)


Test patch:
diff --git a/lib/cartopy/tests/mpl/test_ticker.py b/lib/cartopy/tests/mpl/test_ticker.py
--- a/lib/cartopy/tests/mpl/test_ticker.py
+++ b/lib/cartopy/tests/mpl/test_ticker.py
@@ -46,6 +46,17 @@ def test_LatitudeFormatter():
     assert result == expected
 
 
+def test_LatitudeFormatter_direction_label():
+    formatter = LatitudeFormatter(direction_label=False)
+    p = ccrs.PlateCarree()
+    formatter.axis = Mock(axes=Mock(GeoAxes, projection=p))
+    test_ticks = [-90, -60, -30, 0, 30, 60, 90]
+    result = [formatter(tick) for tick in test_ticks]
+    expected = ['-90\u00B0', '-60\u00B0', '-30\u00B0', '0\u00B0',
+                '30\u00B0', '60\u00B0', '90\u00B0']
+    assert result == expected
+
+
 def test_LatitudeFormatter_degree_symbol():
     formatter = LatitudeFormatter(degree_symbol='')
     p = ccrs.PlateCarree()
@@ -93,6 +104,19 @@ def test_LatitudeFormatter_small_numbers():
     assert result == expected
 
 
+def test_LongitudeFormatter_direction_label():
+    formatter = LongitudeFormatter(direction_label=False,
+                                   dateline_direction_label=True,
+                                   zero_direction_label=True)
+    p = ccrs.PlateCarree()
+    formatter.axis = Mock(axes=Mock(GeoAxes, projection=p))
+    test_ticks = [-180, -120, -60, 0, 60, 120, 180]
+    result = [formatter(tick) for tick in test_ticks]
+    expected = ['-180\u00B0', '-120\u00B0', '-60\u00B0', '0\u00B0',
+                '60\u00B0', '120\u00B0', '180\u00B0']
+    assert result == expected
+
+
 def test_LongitudeFormatter_central_longitude_0():
     formatter = LongitudeFormatter(dateline_direction_label=True)
     p = ccrs.PlateCarree()
@@ -204,6 +228,26 @@ def test_LongitudeFormatter_minutes_seconds(test_ticks, expected):
     assert result == expected
 
 
+@pytest.mark.parametrize("test_ticks,expected",
+                         [pytest.param([-3.75, -3.5],
+                                       ["-3\u00B045'", "-3\u00B030'"],
+                                       id='minutes_no_hide'),
+                          pytest.param([-3.5, -3.],
+                                       ["30'", "-3\u00B0"],
+                                       id='minutes_hide'),
+                          pytest.param([-3. - 2 * ONE_MIN - 30 * ONE_SEC],
+                                       ["-3\u00B02'30''"],
+                                       id='seconds'),
+                          ])
+def test_LongitudeFormatter_minutes_seconds_direction_label(test_ticks,
+                                                            expected):
+    formatter = LongitudeFormatter(
+        dms=True, auto_hide=True, direction_label=False)
+    formatter.set_locs(test_ticks)
+    result = [formatter(tick) for tick in test_ticks]
+    assert result == expected
+
+
 @pytest.mark.parametrize("test_ticks,expected",
                          [pytest.param([-3.75, -3.5],
                                        ["3\u00B0S45'", "3\u00B0S30'"],
@@ -247,7 +291,7 @@ def test_lonlatformatter_non_geoaxes(cls, letter):
                                        id='lon_medium'),
                           pytest.param(LongitudeLocator, -1, 0,
                                        np.array([-60., -50., -40., -30.,
-                                                 -20., -10.,   0.]) / 60,
+                                                 -20., -10., 0.]) / 60,
                                        id='lon_small'),
                           pytest.param(LongitudeLocator, 0, 2 * ONE_MIN,
                                        np.array([0., 18., 36., 54., 72., 90.,