Skip to content

Commit

Permalink
Merge pull request #107 from csiro-coasts/landmarks
Browse files Browse the repository at this point in the history
Add emsarray.plot.add_landmarks(), landmarks parameters
  • Loading branch information
mx-moth committed Sep 21, 2023
2 parents cdedc1a + b8db9f9 commit d629fac
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 21 deletions.
1 change: 1 addition & 0 deletions docs/api/index.rst
Expand Up @@ -19,4 +19,5 @@ API reference
utils.rst
tutorial.rst
plot.rst
types.rst
exceptions.rst
9 changes: 9 additions & 0 deletions docs/api/types.rst
@@ -0,0 +1,9 @@
.. module:: emsarray.types

==============
emsarray.types
==============

.. automodule:: emsarray.types
:noindex:
:members:
1 change: 1 addition & 0 deletions docs/conf.py
Expand Up @@ -88,6 +88,7 @@

# Other documentation that we link to
intersphinx_mapping = {
'cartopy': ('https://scitools.org.uk/cartopy/docs/latest/', None),
'matplotlib': ('https://matplotlib.org/stable/', None),
'numpy': ('https://numpy.org/doc/stable/', None),
'pandas': ('https://pandas.pydata.org/docs/', None),
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/development.rst
Expand Up @@ -18,3 +18,6 @@ Next release (in development)
The old attribute was a compatibility shim around Shapely 1.8.x STRtree implementation.
Now that the minimum version of Shapely is 2.0, the STRtree can be used directly.
(:pr:`103`).
* Add :meth:`emsarray.plot.add_landmarks()`
and `landmarks` parameter to :meth:`Convention.plot` and related functions.
(:pr:`105`).
31 changes: 15 additions & 16 deletions src/emsarray/conventions/_base.py
Expand Up @@ -888,6 +888,7 @@ def plot_on_figure(
scalar: Optional[DataArrayOrName] = None,
vector: Optional[Tuple[DataArrayOrName, DataArrayOrName]] = None,
title: Optional[str] = None,
**kwargs: Any,
) -> None:
"""Plot values for a :class:`~xarray.DataArray`
on a :mod:`matplotlib` :class:`~matplotlib.figure.Figure`.
Expand All @@ -911,12 +912,14 @@ def plot_on_figure(
A tuple of the *u* and *v* components of a vector.
The components should be a :class:`~xarray.DataArray`,
or the name of an existing DataArray in this Dataset.
**kwargs
Any extra keyword arguments are passed on to
:meth:`emsarray.plot.plot_on_figure`
See Also
--------
:func:`.plot.plot_on_figure` : The underlying implementation
"""
kwargs: Dict[str, Any] = {}
if scalar is not None:
kwargs['scalar'] = self._get_data_array(scalar)

Expand Down Expand Up @@ -952,28 +955,22 @@ def plot_on_figure(
plot_on_figure(figure, self, **kwargs)

@_requires_plot
def plot(
self,
scalar: Optional[DataArrayOrName] = None,
vector: Optional[Tuple[DataArrayOrName, DataArrayOrName]] = None,
title: Optional[str] = None,
) -> None:
def plot(self, *args: Any, **kwargs: Any) -> None:
"""Plot a data array and automatically display it.
This method is most useful when working in Jupyter notebooks
which display figures automatically.
This method is a wrapper around :meth:`.plot_on_figure`
that creates and shows a :class:`~matplotlib.figure.Figure` for you.
All arguments are passed on to :meth:`.plot_on_figure`,
refer to that function for details.
See Also
--------
:meth:`.plot_on_figure`
"""
from matplotlib import pyplot
self.plot_on_figure(
pyplot.figure(),
scalar=scalar,
vector=vector,
title=title,
)
self.plot_on_figure(pyplot.figure(), *args, **kwargs)
pyplot.show()

@_requires_plot
Expand All @@ -993,19 +990,21 @@ def animate_on_figure(
Parameters
----------
figure
figure : matplotlib.figure.Figure
The :class:`matplotlib.figure.Figure` to plot the animation on
data_array
data_array : Hashable or xarray.DataArray
The :class:`xarray.DataArray` to plot.
If a string is passed in,
the variable with that name is taken from :attr:`dataset`.
coordinate
coordinate : Hashable or xarray.DataArray, optional
The coordinate to vary across the animation.
Pass in either the name of a coordinate variable
or coordinate variable itself.
Optional, if not supplied the time coordinate
from :meth:`get_time_name` is used.
Other appropriate coordinates to animate over include depth.
**kwargs
Any extra arguments are passed to :func:`.plot.animate_on_figure`.
Returns
-------
Expand Down
103 changes: 102 additions & 1 deletion src/emsarray/plot.py
Expand Up @@ -8,6 +8,7 @@
import numpy
import xarray

from emsarray.types import Landmark
from emsarray.utils import requires_extra

if TYPE_CHECKING:
Expand All @@ -17,7 +18,7 @@
import cartopy.crs
from cartopy.feature import GSHHSFeature
from cartopy.mpl import gridliner
from matplotlib import animation
from matplotlib import animation, patheffects
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.collections import PolyCollection
Expand Down Expand Up @@ -61,12 +62,101 @@ def add_coast(axes: Axes, **kwargs: Any) -> None:


def add_gridlines(axes: Axes) -> gridliner.Gridliner:
"""
Add some gridlines to the axes.
Parameters
----------
axes : :class:`matplotlib.axes.Axes`
The axes to add the gridlines to.
Returns
-------
cartopy.mpl.gridliner.Gridliner
"""
gridlines = axes.gridlines(draw_labels=True, auto_update=True)
gridlines.top_labels = False
gridlines.right_labels = False
return gridlines


def add_landmarks(
axes: Axes,
landmarks: Iterable[Landmark],
color: str = 'black',
outline_color: str = 'white',
outline_width: int = 2,
) -> None:
"""
Place some named landmarks on a plot.
Parameters
----------
axes : matplotlib.axes.Axes
The axes to add the landmarks to.
landmarks : list of :data:`landmarks <emsarray.types.Landmark>`
The landmarks to add. These are tuples of (name, point).
color : str, default 'black'
The color for the landmark marker and labels.
outline_color : str, default 'white'
The color for the outline.
Both the marker and the labels are outlined.
outline_width : ind, default 2
The linewidth of the outline.
Examples
--------
Draw a plot of a specific area with some landmarks:
.. code-block:: python
import emsarray.plot
import shapely
from matplotlib import pyplot
dataset = emsarray.tutorial.open_dataset('gbr4')
# Set up the figure
figure = pyplot.figure()
axes = figure.add_subplot(projection=dataset.ems.data_crs)
axes.set_title("Sea surface temperature around Mackay")
axes.set_aspect('equal', adjustable='datalim')
emsarray.plot.add_coast(axes, zorder=1)
# Focus on the area of interest
axes.set_extent((148.245710, 151.544167, -19.870197, -21.986412))
# Plot the temperature
temperature = dataset.ems.make_poly_collection(
dataset['temp'].isel(time=0, k=-1),
cmap='jet', edgecolor='face', zorder=0)
axes.add_collection(temperature)
figure.colorbar(temperature, label='°C')
# Name key locations
emsarray.plot.add_landmarks(axes, [
('The Percy Group', shapely.Point(150.270579, -21.658269)),
('Whitsundays', shapely.Point(148.955319, -20.169076)),
('Mackay', shapely.Point(149.192671, -21.146719)),
])
figure.show()
"""
outline = patheffects.withStroke(
linewidth=outline_width, foreground=outline_color)

points = axes.scatter(
[p.x for n, p in landmarks], [p.y for n, p in landmarks],
c=color, edgecolors=outline_color, linewidths=outline_width / 2)
points.set_path_effects([outline])

for name, point in landmarks:
text = axes.annotate(
name, (point.x, point.y),
textcoords='offset pixels', xytext=(10, -5))
text.set_path_effects([outline])


def bounds_to_extent(bounds: Tuple[float, float, float, float]) -> List[float]:
"""
Convert a Shapely bounds tuple to a matplotlib extents.
Expand Down Expand Up @@ -136,6 +226,7 @@ def plot_on_figure(
vector: Optional[Tuple[xarray.DataArray, xarray.DataArray]] = None,
title: Optional[str] = None,
projection: Optional[cartopy.crs.Projection] = None,
landmarks: Optional[Iterable[Landmark]] = None,
) -> None:
"""
Plot a :class:`~xarray.DataArray`
Expand All @@ -162,6 +253,8 @@ def plot_on_figure(
Optional, defaults to :class:`~cartopy.crs.PlateCarree`.
This is different to the coordinate reference system for the data,
which is defined in :attr:`.Convention.data_crs`.
landmarks : list of :data:`landmarks <emsarray.types.Landmark>`, optional
Landmarks to add to the plot. These are tuples of (name, point).
"""
if projection is None:
projection = cartopy.crs.PlateCarree()
Expand Down Expand Up @@ -192,6 +285,9 @@ def plot_on_figure(
if title:
axes.set_title(title)

if landmarks:
add_landmarks(axes, landmarks)

add_coast(axes)
add_gridlines(axes)
axes.autoscale()
Expand All @@ -207,6 +303,7 @@ def animate_on_figure(
vector: Optional[Tuple[xarray.DataArray, xarray.DataArray]] = None,
title: Optional[Union[str, Callable[[Any], str]]] = None,
projection: Optional[cartopy.crs.Projection] = None,
landmarks: Optional[Iterable[Landmark]] = None,
interval: int = 1000,
repeat: Union[bool, Literal['cycle', 'bounce']] = True,
) -> animation.FuncAnimation:
Expand Down Expand Up @@ -250,6 +347,8 @@ def animate_on_figure(
Optional, defaults to :class:`~cartopy.crs.PlateCarree`.
This is different to the coordinate reference system for the data,
which is defined in :attr:`.Convention.data_crs`.
landmarks : list of :data:`landmarks <emsarray.types.Landmark>`, optional
Landmarks to add to the plot. These are tuples of (name, point).
interval : int
The interval between frames of animation
repeat : {True, False, 'cycle', 'bounce'}
Expand Down Expand Up @@ -302,6 +401,8 @@ def animate_on_figure(
# Draw a coast overlay
add_coast(axes)
gridlines = add_gridlines(axes)
if landmarks:
add_landmarks(axes, landmarks)
axes.autoscale()

repeat_arg = True
Expand Down
14 changes: 13 additions & 1 deletion src/emsarray/types.py
@@ -1,7 +1,19 @@
"""Collection of type aliases used across the library."""
"""
A collection of descriptive type aliases used across the library.
"""

import os
from typing import Tuple, Union

import shapely

#: Something that can be used as a path.
Pathish = Union[os.PathLike, str]

#: Bounds of a geometry or of an area.
#: Components are ordered as (min x, min y, max x, max y).
Bounds = Tuple[float, float, float, float]

#: A landmark for a plot.
#: This is a tuple of the landmark name and and its location.
Landmark = Tuple[str, shapely.Point]
28 changes: 25 additions & 3 deletions tests/test_plot.py
@@ -1,14 +1,16 @@
import cartopy.crs
import matplotlib.figure
import numpy
import pytest
from shapely.geometry import Polygon
import shapely

from emsarray.plot import polygons_to_collection
from emsarray.plot import add_landmarks, polygons_to_collection


@pytest.mark.matplotlib
def test_polygons_to_collection():
polygons = [
Polygon([(i, 0), (i + 1, 0), (i + 1, 1), (i, 1), (i, 0)])
shapely.Polygon([(i, 0), (i + 1, 0), (i + 1, 1), (i, 1), (i, 0)])
for i in range(10)
]
data = numpy.random.random(10) * 10
Expand All @@ -21,3 +23,23 @@ def test_polygons_to_collection():
# Check that keyword arguments were passed through
assert patch_collection.get_cmap().name == 'autumn'
numpy.testing.assert_equal(patch_collection.get_array(), data)


@pytest.mark.matplotlib
def test_add_landmarks():
figure = matplotlib.figure.Figure()
axes = figure.add_subplot(projection=cartopy.crs.PlateCarree())

landmarks = [
('Origin', shapely.Point(0, 0)),
('São Tomé and Príncipe', shapely.Point(6.607735, 0.2633684)),
('San Antonio de Palé', shapely.Point(5.640007, -1.428858)),
('Bela Vista', shapely.Point(7.410149, 1.614794)),
('Bioko', shapely.Point(8.745365, 3.433421)),
]
add_landmarks(axes, landmarks)

assert len(landmarks) == len(axes.texts)
for landmark, text in zip(landmarks, axes.texts):
assert text.get_text() == landmark[0]
assert text.xy == landmark[1].coords.xy

0 comments on commit d629fac

Please sign in to comment.