diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 04051b667c8..2618cb2410a 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -3,16 +3,16 @@ # SPDX-License-Identifier: BSD-3-Clause """Declarative plotting tools.""" -from collections import Counter import contextlib import copy from datetime import datetime, timedelta +import re import matplotlib.pyplot as plt import numpy as np import pandas as pd -from traitlets import (Any, Bool, Float, HasTraits, Instance, Int, List, observe, Tuple, - Unicode, Union) +from traitlets import (Any, Bool, Float, HasTraits, Instance, Int, List, observe, TraitError, + Tuple, Unicode, Union, validate) from . import ctables from . import wx_symbols @@ -660,6 +660,40 @@ class MapPanel(Panel): 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'. """ + @validate('area') + def _valid_area(self, proposal): + """Check that proposed string or tuple is valid and turn string into a tuple extent.""" + area = proposal['value'] + + # Parse string, check that string is valid, and determine extent based on string + if isinstance(area, str): + match = re.match(r'(\w+)([-+]*)$', area) + if match is None: + raise TraitError(f'"{area}" is not a valid string area.') + region, modifier = match.groups() + region = region.lower() + + if region == 'global': + extent = 'global' + elif region in _areas: + extent = _areas[region] + zoom = modifier.count('+') - modifier.count('-') + extent = self._zoom_extent(extent, zoom) + else: + raise TraitError(f'"{area}" is not a valid string area.') + # Otherwise, assume area is a tuple and check that latitudes/longitudes are valid + else: + west_lon, east_lon, south_lat, north_lat = area + valid_west = -180 <= west_lon <= 180 + valid_east = -180 <= east_lon <= 180 + valid_south = -90 <= south_lat <= 90 + valid_north = -90 <= north_lat <= 90 + if not (valid_west and valid_east and valid_south and valid_north): + raise TraitError(f'"{area}" is not a valid string area.') + extent = area + + return extent + @observe('plots') def _plots_changed(self, change): """Handle when our collection of plots changes.""" @@ -724,24 +758,17 @@ def _zoom_extent(extent, zoom): If ``zoom`` < 0, the returned extent will be larger (zoomed out) """ - # Measure the current extent - center_lon = (extent[0] + extent[1]) / 2 - center_lat = (extent[2] + extent[3]) / 2 - lon_range = extent[1] - extent[0] - lat_range = extent[3] - extent[2] - - # Transforming zoom by e^-x prevents multiplication by zero or a negative number below - zoom_multiplier = np.exp(-0.5 * zoom) + west_lon, east_lon, south_lat, north_lat = extent - # Calculate "width" and "height" of new, zoomed extent - new_lon_range = lon_range * zoom_multiplier - new_lat_range = lat_range * zoom_multiplier + # Turn number of pluses and minuses into a number than can scale the latitudes and + # longitudes of our extent + zoom_multiplier = (1 - 2**-zoom) / 2 - # Calculate bounds for new, zoomed extent with new "width" and "height" - new_west_lon = center_lon - 0.5 * new_lon_range - new_east_lon = center_lon + 0.5 * new_lon_range - new_south_lat = center_lat - 0.5 * new_lat_range - new_north_lat = center_lat + 0.5 * new_lat_range + # Calculate bounds for new, zoomed extent + new_north_lat = north_lat + (south_lat - north_lat) * zoom_multiplier + new_south_lat = south_lat - (south_lat - north_lat) * zoom_multiplier + new_east_lon = east_lon + (west_lon - east_lon) * zoom_multiplier + new_west_lon = west_lon - (west_lon - east_lon) * zoom_multiplier return (new_west_lon, new_east_lon, new_south_lat, new_north_lat) @@ -779,26 +806,11 @@ def draw(self): # Only need to run if we've actually changed. if self._need_redraw: - # Set the extent as appropriate based on the area. One special case for 'global' + # Set the extent as appropriate based on the area. One special case for 'global'. if self.area == 'global': self.ax.set_global() elif self.area is not None: - # Get extent from specified area and zoom in/out with '+' or '-' suffix - if isinstance(self.area, str) and ('+' in self.area or '-' in self.area): - pos = [self.area.find('+'), self.area.find('-')] - split_pos = min([i for i in pos if i > 0]) - area = self.area[:split_pos] - modifier = self.area[split_pos:] - extent = _areas[area.lower()] - zoom = Counter(modifier)['+'] - Counter(modifier)['-'] - extent = self._zoom_extent(extent, zoom) - # Get extent from specified area - elif isinstance(self.area, str): - extent = _areas[self.area.lower()] - # Otherwise, assume we have a tuple to use as the extent - else: - extent = self.area - self.ax.set_extent(extent, ccrs.PlateCarree()) + self.ax.set_extent(self.area, ccrs.PlateCarree()) # Draw all of the plots. for p in self.plots: diff --git a/tests/plots/baseline/test_declarative_region_modifier_zoom_in.png b/tests/plots/baseline/test_declarative_region_modifier_zoom_in.png index 8483e48a370..463ab662a4d 100644 Binary files a/tests/plots/baseline/test_declarative_region_modifier_zoom_in.png and b/tests/plots/baseline/test_declarative_region_modifier_zoom_in.png differ diff --git a/tests/plots/baseline/test_declarative_region_modifier_zoom_out.png b/tests/plots/baseline/test_declarative_region_modifier_zoom_out.png index 2f75425c92f..e9ede5aac87 100644 Binary files a/tests/plots/baseline/test_declarative_region_modifier_zoom_out.png and b/tests/plots/baseline/test_declarative_region_modifier_zoom_out.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 37da5ec28ba..a4105a019b0 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -1329,6 +1329,24 @@ def test_declarative_region_modifier_zoom_out(): return pc.figure +@needs_cartopy +def test_declarative_bad_area(): + """Test that a invalid string or tuple provided to the area trait raises an error.""" + panel = MapPanel() + + # Test for string that cannot be grouped into a region and a modifier by regex + with pytest.raises(TraitError): + panel.area = 'a$z+' + + # Test for string that is not in our list of areas + with pytest.raises(TraitError): + panel.area = 'PS' + + # Test for nonsense coordinates + with pytest.raises(TraitError): + panel.area = (136, -452, -65, -88) + + def test_save(): """Test that our saving function works.""" pc = PanelContainer()