diff --git a/.github/workflows/tests-pypi.yml b/.github/workflows/tests-pypi.yml index e8b3db8ab7a..64f8d04346e 100644 --- a/.github/workflows/tests-pypi.yml +++ b/.github/workflows/tests-pypi.yml @@ -84,13 +84,15 @@ jobs: # This installs the stuff needed to build and install Shapely and CartoPy from source. # Need to install numpy first to make CartoPy happy. - - name: Install dependencies + - name: Install CartoPy build dependencies + if: ${{ matrix.no-extras != 'No Extras' }} run: | sudo apt-get install libgeos-dev libproj-dev proj-bin - python -m pip install --upgrade pip setuptools python -m pip install --no-binary :all: shapely python -m pip install -c ci/${{ matrix.dep-versions }} numpy - python -m pip install -r ci/test_requirements.txt -c ci/${{ matrix.dep-versions }} + + - name: Install test dependencies + run: python -m pip install -r ci/test_requirements.txt -c ci/${{ matrix.dep-versions }} # This imports CartoPy to find its map data cache directory - name: Get CartoPy maps dir diff --git a/ci/linting_requirements.txt b/ci/linting_requirements.txt index a1d849fbf78..4eb7c221307 100644 --- a/ci/linting_requirements.txt +++ b/ci/linting_requirements.txt @@ -10,7 +10,6 @@ flake8-copyright==0.2.2 flake8-isort==4.0.0 isort==5.9.2 flake8-mutable==1.2.0 -flake8-pep3101==1.3.0 flake8-print==4.0.0 flake8-quotes==3.2.0 flake8-simplify==0.14.1 diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 03fd8e0a784..f5558531d44 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -2,12 +2,14 @@ # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause """Declarative plotting tools.""" - +import collections import contextlib import copy from datetime import datetime, timedelta +from itertools import cycle import re +import matplotlib.patheffects as patheffects import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -15,6 +17,7 @@ Tuple, Unicode, Union, validate) from . import ctables, wx_symbols +from ._mpl import TextCollection from .cartopy_utils import import_cartopy from .station_plot import StationPlot from ..calc import reduce_point_density @@ -1831,3 +1834,271 @@ def _build(self): def copy(self): """Return a copy of the plot.""" return copy.copy(self) + + +@exporter.export +class PlotGeometry(HasTraits): + """Plot collections of Shapely objects and customize their appearance.""" + + parent = Instance(Panel) + _need_redraw = Bool(default_value=True) + + geometry = Instance(collections.abc.Iterable, allow_none=False) + geometry.__doc__ = """A collection of Shapely objects to plot. + + A collection of Shapely objects, such as the 'geometry' column from a + ``geopandas.GeoDataFrame``. Acceptable Shapely objects are ``shapely.MultiPolygon``, + ``shapely.Polygon``, ``shapely.MultiLineString``, ``shapely.LineString``, + ``shapely.MultiPoint``, and ``shapely.Point``. + """ + + fill = Union([Instance(collections.abc.Iterable), Unicode()], default_value=['lightgray'], + allow_none=True) + fill.__doc__ = """Fill color(s) for polygons and points. + + A single string (color name or hex code) or collection of strings with which to fill + polygons and points. If a collection, the first color corresponds to the first Shapely + object in `geometry`, the second color corresponds to the second Shapely object, and so on. + If `fill` is shorter than `geometry`, `fill` cycles back to the beginning, repeating the + sequence of colors as needed. Default value is lightgray. + """ + + stroke = Union([Instance(collections.abc.Iterable), Unicode()], default_value=['black'], + allow_none=True) + stroke.__doc__ = """Stroke color(s) for polygons and line color(s) for lines. + + A single string (color name or hex code) or collection of strings with which to outline + polygons and color lines. If a collection, the first color corresponds to the first Shapely + object in `geometry`, the second color corresponds to the second Shapely object, and so on. + If `stroke` is shorter than `geometry`, `stroke` cycles back to the beginning, repeating + the sequence of colors as needed. Default value is black. + """ + + marker = Unicode(default_value='.', allow_none=False) + marker.__doc__ = """Symbol used to denote points. + + Accepts any matplotlib marker. Default value is '.', which plots a dot at each point. + """ + + labels = Instance(collections.abc.Iterable, allow_none=True) + labels.__doc__ = """A collection of labels corresponding to plotted geometry. + + A collection of strings to use as labels for geometry, such as a column from a + ``Geopandas.GeoDataFrame``. The first label corresponds to the first Shapely object in + `geometry`, the second label corresponds to the second Shapely object, and so on. The + length of `labels` must be equal to the length of `geometry`. Labels are positioned along + the edge of polygons, and below lines and points. No labels are plotted if this attribute + is left undefined, or set equal to `None`. + """ + + label_fontsize = Union([Int(), Float(), Unicode()], default_value=None, allow_none=True) + label_fontsize.__doc__ = """An integer or string value for the font size of labels. + + Accepts size in points or relative size. Allowed relative sizes are those of Matplotlib: + 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'. + """ + + label_facecolor = Union([Instance(collections.abc.Iterable), Unicode()], allow_none=True) + label_facecolor.__doc__ = """Font color(s) for labels. + + A single string (color name or hex code) or collection of strings for the font color of + labels. If a collection, the first color corresponds to the label of the first Shapely + object in `geometry`, the second color corresponds to the label of the second Shapely + object, and so on. Default value is `stroke`. + """ + + label_edgecolor = Union([Instance(collections.abc.Iterable), Unicode()], allow_none=True) + label_edgecolor.__doc__ = """Outline color(s) for labels. + + A single string (color name or hex code) or collection of strings for the outline color of + labels. If a collection, the first color corresponds to the label of the first Shapely + object in `geometry`, the second color corresponds to the label of the second Shapely + object, and so on. Default value is `fill`. + """ + + @staticmethod + @validate('geometry') + def _valid_geometry(_, proposal): + """Cast `geometry` into a list once it is provided by user. + + Users can provide any kind of collection, such as a ``GeoPandas.GeoSeries``, and this + turns them into a list. + """ + geometry = proposal['value'] + return list(geometry) + + @staticmethod + @validate('fill', 'stroke', 'label_facecolor', 'label_edgecolor') + def _valid_color_list(_, proposal): + """Cast color-related attributes into a list once provided by user. + + This is necessary because _build() expects to cycle through a list of colors when + assigning them to the geometry. + """ + color = proposal['value'] + + if isinstance(color, str): + color = [color] + # `color` must be a collection if it is not a string + else: + color = list(color) + + return color + + @staticmethod + @validate('labels') + def _valid_labels(_, proposal): + """Cast `labels` into a list once provided by user.""" + labels = proposal['value'] + return list(labels) + + @observe('fill', 'stroke') + def _update_label_colors(self, change): + """Set default text colors using `fill` and `stroke`. + + If `label_facecolor` or `label_edgecolor` have not been specified, provide default + colors for those attributes using `fill` and `stroke`. + """ + if change['name'] == 'fill' and self.label_edgecolor is None: + self.label_edgecolor = self.fill + elif change['name'] == 'stroke' and self.label_facecolor is None: + self.label_facecolor = self.stroke + + @property + def name(self): + """Generate a name for the plot.""" + # Unlike Plots2D and PlotObs, there are no other attributes (such as 'fields' or + # 'levels') from which to name the plot. A generic name is returned here in case the + # user does not provide their own title, in which case MapPanel.draw() looks here. + return 'Geometry Plot' + + @staticmethod + def _position_label(geo_obj, label): + """Return a (lon, lat) where the label of a polygon/line/point can be placed.""" + from shapely.geometry import MultiLineString, MultiPoint, MultiPolygon, Polygon + + # A hash of the label is used in choosing a point along the polygon or line that + # will be returned. This "psuedo-randomizes" the position of a label, in hopes of + # spatially dispersing the labels and lessening the chance that labels overlap. + label_hash = sum(map(ord, str(label))) + + # If object is a MultiPolygon or MultiLineString, associate the label with the single + # largest Polygon or LineString from the collection. If MultiPoint, associate the label + # with one of the Points in the MultiPoint, chosen based on the label hash. + if isinstance(geo_obj, (MultiPolygon, MultiLineString)): + geo_obj = max(geo_obj, key=lambda x: x.length) + elif isinstance(geo_obj, MultiPoint): + geo_obj = geo_obj[label_hash % len(geo_obj)] + + # Get the list of coordinates of the polygon/line/point + if isinstance(geo_obj, Polygon): + coords = geo_obj.exterior.coords + else: + coords = geo_obj.coords + + position = coords[label_hash % len(coords)] + + return position + + def _draw_label(self, text, lon, lat, color='black', outline='white', offset=(0, 0)): + """Draw a label to the plot. + + Parameters + ---------- + text : str + The label's text + lon : float + Longitude at which to position the label + lat : float + Latitude at which to position the label + color : str (default: 'black') + Name or hex code for the color of the text + outline : str (default: 'white') + Name or hex code of the color of the outline of the text + offset : tuple (default: (0, 0)) + A tuple containing the x- and y-offset of the label, respectively + """ + path_effects = [patheffects.withStroke(linewidth=4, foreground=outline)] + self.parent.ax.add_collection(TextCollection([lon], [lat], [str(text)], + va='center', + ha='center', + offset=offset, + weight='demi', + size=self.label_fontsize, + color=color, + path_effects=path_effects, + transform=ccrs.PlateCarree())) + + def draw(self): + """Draw the plot.""" + if self._need_redraw: + if getattr(self, 'handles', None) is None: + self._build() + self._need_redraw = False + + def copy(self): + """Return a copy of the plot.""" + return copy.copy(self) + + def _build(self): + """Build the plot by calling needed plotting methods as necessary.""" + from shapely.geometry import (LineString, MultiLineString, MultiPoint, MultiPolygon, + Point, Polygon) + + # Cast attributes to a list if None, since traitlets doesn't call validators (like + # `_valid_color_list()` and `_valid_labels()`) when the proposed value is None. + self.fill = ['none'] if self.fill is None else self.fill + self.stroke = ['none'] if self.stroke is None else self.stroke + self.labels = [''] if self.labels is None else self.labels + self.label_edgecolor = (['none'] if self.label_edgecolor is None + else self.label_edgecolor) + self.label_facecolor = (['none'] if self.label_facecolor is None + else self.label_facecolor) + + # Each Shapely object is plotted separately with its corresponding colors and label + for geo_obj, stroke, fill, label, fontcolor, fontoutline in zip( + self.geometry, cycle(self.stroke), cycle(self.fill), cycle(self.labels), + cycle(self.label_facecolor), cycle(self.label_edgecolor)): + # Plot the Shapely object with the appropriate method and colors + if isinstance(geo_obj, (MultiPolygon, Polygon)): + self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, + facecolor=fill, crs=ccrs.PlateCarree()) + elif isinstance(geo_obj, (MultiLineString, LineString)): + self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, + facecolor='none', crs=ccrs.PlateCarree()) + elif isinstance(geo_obj, MultiPoint): + for point in geo_obj: + lon, lat = point.coords[0] + self.parent.ax.plot(lon, lat, color=fill, marker=self.marker, + transform=ccrs.PlateCarree()) + elif isinstance(geo_obj, Point): + lon, lat = geo_obj.coords[0] + self.parent.ax.plot(lon, lat, color=fill, marker=self.marker, + transform=ccrs.PlateCarree()) + + # Plot labels if provided + if label: + # If fontcolor is None/'none', choose a font color + if fontcolor in [None, 'none'] and stroke not in [None, 'none']: + fontcolor = stroke + elif fontcolor in [None, 'none']: + fontcolor = 'black' + + # If fontoutline is None/'none', choose a font outline + if fontoutline in [None, 'none'] and fill not in [None, 'none']: + fontoutline = fill + elif fontoutline in [None, 'none']: + fontoutline = 'white' + + # Choose a point along the polygon/line/point to place label + lon, lat = self._position_label(geo_obj, label) + + # If polygon, put label directly on edge of polygon. If line or point, put + # label slightly below line/point. + if isinstance(geo_obj, (MultiPolygon, Polygon)): + offset = (0, 0) + else: + offset = (0, -12) + + # Finally, draw the label + self._draw_label(label, lon, lat, fontcolor, fontoutline, offset) diff --git a/tests/plots/baseline/test_declarative_plot_geometry_lines.png b/tests/plots/baseline/test_declarative_plot_geometry_lines.png new file mode 100644 index 00000000000..d35714c0c1c Binary files /dev/null and b/tests/plots/baseline/test_declarative_plot_geometry_lines.png differ diff --git a/tests/plots/baseline/test_declarative_plot_geometry_points.png b/tests/plots/baseline/test_declarative_plot_geometry_points.png new file mode 100644 index 00000000000..d832132f02a Binary files /dev/null and b/tests/plots/baseline/test_declarative_plot_geometry_points.png differ diff --git a/tests/plots/baseline/test_declarative_plot_geometry_polygons.png b/tests/plots/baseline/test_declarative_plot_geometry_polygons.png new file mode 100644 index 00000000000..8756f5f8a34 Binary files /dev/null and b/tests/plots/baseline/test_declarative_plot_geometry_polygons.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 8ad99c9a664..5ab52aee9f2 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -18,7 +18,7 @@ from metpy.io import GiniFile from metpy.io.metar import parse_metar_file from metpy.plots import (BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, - PanelContainer, PlotObs) + PanelContainer, PlotGeometry, PlotObs) from metpy.testing import needs_cartopy from metpy.units import units @@ -1426,7 +1426,8 @@ def test_panel(): def test_copy(): """Test that the copy method works for all classes in `declarative.py`.""" # Copies of plot objects - objects = [ImagePlot(), ContourPlot(), FilledContourPlot(), BarbPlot(), PlotObs()] + objects = [ImagePlot(), ContourPlot(), FilledContourPlot(), BarbPlot(), PlotObs(), + PlotGeometry()] for obj in objects: obj.time = datetime.now() @@ -1449,8 +1450,132 @@ def test_copy(): # Copies of plots in MapPanels should not point to same location in memory obj = MapPanel() - obj.plots = [PlotObs(), BarbPlot(), FilledContourPlot(), ContourPlot(), ImagePlot()] + obj.plots = [PlotObs(), PlotGeometry(), BarbPlot(), FilledContourPlot(), ContourPlot(), + ImagePlot()] copied_obj = obj.copy() for i in range(len(obj.plots)): assert obj.plots[i] is not copied_obj.plots[i] + + +@pytest.mark.mpl_image_compare(remove_text=False, tolerance=0.607) +@needs_cartopy +def test_declarative_plot_geometry_polygons(): + """Test that `PlotGeometry` correctly plots MultiPolygon and Polygon objects.""" + from shapely.geometry import MultiPolygon, Polygon + + # MultiPolygons and Polygons to plot + slgt_risk_polygon = MultiPolygon([Polygon( + [(-87.43, 41.86), (-91.13, 41.39), (-95.24, 40.99), (-97.47, 40.4), (-98.39, 41.38), + (-96.54, 42.44), (-94.02, 44.48), (-92.62, 45.48), (-89.49, 45.91), (-86.38, 44.92), + (-86.26, 43.37), (-86.62, 42.45), (-87.43, 41.86), ]), Polygon( + [(-74.02, 42.8), (-72.01, 43.08), (-71.42, 42.77), (-71.76, 42.29), (-72.73, 41.89), + (-73.89, 41.93), (-74.4, 42.28), (-74.02, 42.8), ])]) + enh_risk_polygon = Polygon( + [(-87.42, 43.67), (-88.44, 42.65), (-90.87, 41.92), (-94.63, 41.84), (-95.13, 42.22), + (-95.23, 42.54), (-94.79, 43.3), (-92.81, 43.99), (-90.62, 44.55), (-88.51, 44.61), + (-87.42, 43.67)]) + + # Plot geometry, set colors and labels + geo = PlotGeometry() + geo.geometry = [slgt_risk_polygon, enh_risk_polygon] + geo.stroke = ['#DDAA00', '#FF6600'] + geo.fill = None + geo.labels = ['SLGT', 'ENH'] + geo.label_facecolor = ['#FFE066', '#FFA366'] + geo.label_edgecolor = ['#DDAA00', '#FF6600'] + geo.label_fontsize = 'large' + + # Place plot in a panel and container + panel = MapPanel() + panel.area = [-125, -70, 20, 55] + panel.projection = 'lcc' + panel.title = ' ' + panel.layers = ['coastline', 'borders', 'usstates'] + panel.plots = [geo] + + pc = PanelContainer() + pc.size = (12, 12) + pc.panels = [panel] + pc.show() + + return pc.figure + + +@pytest.mark.mpl_image_compare(remove_text=False, tolerance=2.985) +def test_declarative_plot_geometry_lines(ccrs): + """Test that `PlotGeometry` correctly plots MultiLineString and LineString objects.""" + from shapely.geometry import LineString, MultiLineString + + # LineString and MultiLineString to plot + irma_fcst = LineString( + [(-52.3, 16.9), (-53.9, 16.7), (-56.2, 16.6), (-58.6, 17.0), (-61.2, 17.8), + (-63.9, 18.7), (-66.8, 19.6), (-72.0, 21.0), (-76.5, 22.0)]) + irma_fcst_shadow = MultiLineString([LineString( + [(-52.3, 17.15), (-53.9, 16.95), (-56.2, 16.85), (-58.6, 17.25), (-61.2, 18.05), + (-63.9, 18.95), (-66.8, 19.85), (-72.0, 21.25), (-76.5, 22.25)]), LineString( + [(-52.3, 16.65), (-53.9, 16.45), (-56.2, 16.35), (-58.6, 16.75), (-61.2, 17.55), + (-63.9, 18.45), (-66.8, 19.35), (-72.0, 20.75), (-76.5, 21.75)])]) + + # Plot geometry, set colors and labels + geo = PlotGeometry() + geo.geometry = [irma_fcst, irma_fcst_shadow] + geo.fill = None + geo.stroke = 'green' + geo.labels = ['Irma', '+/- 0.25 deg latitude'] + geo.label_facecolor = None + + # Place plot in a panel and container + panel = MapPanel() + panel.area = [-85, -45, 12, 25] + panel.projection = ccrs.PlateCarree() + panel.layers = ['coastline', 'borders', 'usstates'] + panel.title = 'Hurricane Irma Forecast' + panel.plots = [geo] + + pc = PanelContainer() + pc.size = (12, 12) + pc.panels = [panel] + pc.show() + + return pc.figure + + +@pytest.mark.mpl_image_compare(remove_text=False, tolerance=1.900) +def test_declarative_plot_geometry_points(ccrs): + """Test that `PlotGeometry` correctly plots Point and MultiPoint objects.""" + from shapely.geometry import MultiPoint, Point + + # Points and MultiPoints to plot + irma_track = [Point(-74.7, 21.8), Point(-76.0, 22.0), Point(-77.2, 22.1)] + irma_track_shadow = MultiPoint([ + Point(-64.7, 18.25), Point(-66.0, 18.85), Point(-67.7, 19.45), Point(-69.0, 19.85), + Point(-70.4, 20.45), Point(-71.8, 20.85), Point(-73.2, 21.25), Point(-74.7, 21.55), + Point(-76.0, 21.75), Point(-77.2, 21.85), Point(-78.3, 22.05), Point(-79.3, 22.45), + Point(-80.2, 22.85), Point(-80.9, 23.15), Point(-81.3, 23.45), Point(-81.5, 24.25), + Point(-81.7, 25.35), Point(-81.7, 26.55), Point(-82.2, 27.95), Point(-82.7, 29.35), + Point(-83.5, 30.65), Point(-84.4, 31.65)]) + + # Plot geometry, set colors and labels + geo = PlotGeometry() + geo.geometry = irma_track + [irma_track_shadow] + geo.fill = 'blue' + geo.stroke = None + geo.marker = '^' + geo.labels = ['Point', 'Point', 'Point', 'Irma Track'] + geo.label_edgecolor = None + geo.label_facecolor = None + + # Place plot in a panel and container + panel = MapPanel() + panel.area = [-85, -65, 17, 30] + panel.projection = ccrs.PlateCarree() + panel.layers = ['states', 'coastline', 'borders'] + panel.plots = [geo] + + pc = PanelContainer() + pc.size = (12, 12) + pc.panels = [panel] + pc.show() + + return pc.figure