acgc.map_scalebar

  1#!/usr/bin/env python3
  2""" Scale bar for display on maps
  3
  4From StackOverflow
  5https://stackoverflow.com/questions/32333870/how-can-i-show-a-km-ruler-on-a-cartopy-matplotlib-plot
  6"""
  7
  8import numpy as np
  9import cartopy.crs as ccrs
 10import cartopy.geodesic as cgeo
 11
 12
 13def _axes_to_lonlat(ax, coords):
 14    """(lon, lat) from axes coordinates."""
 15    display = ax.transAxes.transform(coords)
 16    data = ax.transData.inverted().transform(display)
 17    lonlat = ccrs.PlateCarree().transform_point(*data, ax.projection)
 18
 19    return lonlat
 20
 21
 22def _upper_bound(start, direction, distance, dist_func):
 23    """A point farther than distance from start, in the given direction.
 24
 25    It doesn't matter which coordinate system start is given in, as long
 26    as dist_func takes points in that coordinate system.
 27
 28    Args:
 29        start:     Starting point for the line.
 30        direction  Nonzero (2, 1)-shaped array, a direction vector.
 31        distance:  Positive distance to go past.
 32        dist_func: A two-argument function which returns distance.
 33
 34    Returns:
 35        Coordinates of a point (a (2, 1)-shaped NumPy array).
 36    """
 37    if distance <= 0:
 38        raise ValueError(f"Minimum distance is not positive: {distance}")
 39
 40    if np.linalg.norm(direction) == 0:
 41        raise ValueError("Direction vector must not be zero.")
 42
 43    # Exponential search until the distance between start and end is
 44    # greater than the given limit.
 45    length = 0.1
 46    end = start + length * direction
 47
 48    while dist_func(start, end) < distance:
 49        length *= 2
 50        end = start + length * direction
 51
 52    return end
 53
 54
 55def _distance_along_line(start, end, distance, dist_func, tol):
 56    """Point at a distance from start on the segment  from start to end.
 57
 58    It doesn't matter which coordinate system start is given in, as long
 59    as dist_func takes points in that coordinate system.
 60
 61    Args:
 62        start:     Starting point for the line.
 63        end:       Outer bound on point's location.
 64        distance:  Positive distance to travel.
 65        dist_func: Two-argument function which returns distance.
 66        tol:       Relative error in distance to allow.
 67
 68    Returns:
 69        Coordinates of a point (a (2, 1)-shaped NumPy array).
 70    """
 71    initial_distance = dist_func(start, end)
 72    if initial_distance < distance:
 73        raise ValueError(f"End is closer to start ({initial_distance}) than "
 74                         f"given distance ({distance}).")
 75
 76    if tol <= 0:
 77        raise ValueError(f"Tolerance is not positive: {tol}")
 78
 79    # Binary search for a point at the given distance.
 80    left = start
 81    right = end
 82
 83    while not np.isclose(dist_func(start, right), distance, rtol=tol):
 84        midpoint = (left + right) / 2
 85
 86        # If midpoint is too close, search in second half.
 87        if dist_func(start, midpoint) < distance:
 88            left = midpoint
 89        # Otherwise the midpoint is too far, so search in first half.
 90        else:
 91            right = midpoint
 92
 93    return right
 94
 95
 96def _point_along_line(ax, start, distance, angle=0, tol=0.01):
 97    """Point at a given distance from start at a given angle.
 98
 99    Args:
100        ax:       CartoPy axes.
101        start:    Starting point for the line in axes coordinates.
102        distance: Positive physical distance to travel.
103        angle:    Anti-clockwise angle for the bar, in radians. Default: 0
104        tol:      Relative error in distance to allow. Default: 0.01
105
106    Returns:
107        Coordinates of a point (a (2, 1)-shaped NumPy array).
108    """
109    # Direction vector of the line in axes coordinates.
110    direction = np.array([np.cos(angle), np.sin(angle)])
111
112    geodesic = cgeo.Geodesic()
113
114    # Physical distance between points.
115    def dist_func(a_axes, b_axes):
116        a_phys = _axes_to_lonlat(ax, a_axes)
117        b_phys = _axes_to_lonlat(ax, b_axes)
118
119        # Geodesic().inverse returns a NumPy MemoryView like [[distance,
120        # start azimuth, end azimuth]].
121        return geodesic.inverse(a_phys, b_phys).base[0, 0]
122
123    end = _upper_bound(start, direction, distance, dist_func)
124
125    return _distance_along_line(start, end, distance, dist_func, tol)
126
127
128def scale_bar(ax, location, length, metres_per_unit=1000, unit_name='km',
129              tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005,
130              ha='center', va='bottom', plot_kwargs=None, text_kwargs=None,
131              **kwargs):
132    """Add a scale bar to CartoPy axes.
133
134    For angles between 0 and 90 the text and line may be plotted at
135    slightly different angles for unknown reasons. To work around this,
136    override the 'rotation' keyword argument with text_kwargs.
137
138    Parameters
139    ----------
140    ax:              
141        CartoPy axes
142    location:        
143        Position of left-side of bar in axes coordinates.
144    length:          
145        Geodesic length of the scale bar.
146    metres_per_unit: default=1000
147        Number of metres in the given unit.
148    unit_name: str, default='km'       
149        Name of the given unit.
150    tol: float, default=0.01             
151        Allowed relative error in length of bar
152    angle: float           
153        Anti-clockwise rotation of the bar.
154    color: str, default='black'           
155        Color of the bar and text.
156    linewidth: float       
157        Same argument as for plot.
158    text_offset: float, default=0.005     
159        Perpendicular offset for text in axes coordinates.
160    ha: str or float [0-1], default='center'              
161        Horizontal alignment.
162    va: str or float [0-1], default='bottom'              
163        Vertical alignment.
164    plot_kwargs: dict
165        Keyword arguments for plot, overridden by **kwargs.
166    text_kwargs: dict
167        Keyword arguments for text, overridden by **kwargs.
168    **kwargs:        
169        Keyword arguments for both plot and text.
170    """
171    # Setup kwargs, update plot_kwargs and text_kwargs.
172    if plot_kwargs is None:
173        plot_kwargs = {}
174    if text_kwargs is None:
175        text_kwargs = {}
176
177    plot_kwargs = {'linewidth': linewidth, 'color': color, **plot_kwargs,
178                   **kwargs}
179    text_kwargs = {'ha': ha, 'va': va, 'rotation': angle, 'color': color,
180                   **text_kwargs, **kwargs}
181
182    # Convert all units and types.
183    location = np.asarray(location)  # For vector addition.
184    length_metres = length * metres_per_unit
185    angle_rad = angle * np.pi / 180
186
187    # End-point of bar.
188    end = _point_along_line(ax, location, length_metres, angle=angle_rad,
189                            tol=tol)
190
191    # Coordinates are currently in axes coordinates, so use transAxes to
192    # put into data coordinates. *zip(a, b) produces a list of x-coords,
193    # then a list of y-coords.
194    ax.plot(*zip(location, end), transform=ax.transAxes, **plot_kwargs)
195
196    # Push text away from bar in the perpendicular direction.
197    midpoint = (location + end) / 2
198    offset = text_offset * np.array([-np.sin(angle_rad), np.cos(angle_rad)])
199    text_location = midpoint + offset
200
201    # 'rotation' keyword argument is in text_kwargs.
202    ax.text(*text_location, f"{length} {unit_name}", rotation_mode='anchor',
203            transform=ax.transAxes, **text_kwargs)
def scale_bar( ax, location, length, metres_per_unit=1000, unit_name='km', tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005, ha='center', va='bottom', plot_kwargs=None, text_kwargs=None, **kwargs):
129def scale_bar(ax, location, length, metres_per_unit=1000, unit_name='km',
130              tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005,
131              ha='center', va='bottom', plot_kwargs=None, text_kwargs=None,
132              **kwargs):
133    """Add a scale bar to CartoPy axes.
134
135    For angles between 0 and 90 the text and line may be plotted at
136    slightly different angles for unknown reasons. To work around this,
137    override the 'rotation' keyword argument with text_kwargs.
138
139    Parameters
140    ----------
141    ax:              
142        CartoPy axes
143    location:        
144        Position of left-side of bar in axes coordinates.
145    length:          
146        Geodesic length of the scale bar.
147    metres_per_unit: default=1000
148        Number of metres in the given unit.
149    unit_name: str, default='km'       
150        Name of the given unit.
151    tol: float, default=0.01             
152        Allowed relative error in length of bar
153    angle: float           
154        Anti-clockwise rotation of the bar.
155    color: str, default='black'           
156        Color of the bar and text.
157    linewidth: float       
158        Same argument as for plot.
159    text_offset: float, default=0.005     
160        Perpendicular offset for text in axes coordinates.
161    ha: str or float [0-1], default='center'              
162        Horizontal alignment.
163    va: str or float [0-1], default='bottom'              
164        Vertical alignment.
165    plot_kwargs: dict
166        Keyword arguments for plot, overridden by **kwargs.
167    text_kwargs: dict
168        Keyword arguments for text, overridden by **kwargs.
169    **kwargs:        
170        Keyword arguments for both plot and text.
171    """
172    # Setup kwargs, update plot_kwargs and text_kwargs.
173    if plot_kwargs is None:
174        plot_kwargs = {}
175    if text_kwargs is None:
176        text_kwargs = {}
177
178    plot_kwargs = {'linewidth': linewidth, 'color': color, **plot_kwargs,
179                   **kwargs}
180    text_kwargs = {'ha': ha, 'va': va, 'rotation': angle, 'color': color,
181                   **text_kwargs, **kwargs}
182
183    # Convert all units and types.
184    location = np.asarray(location)  # For vector addition.
185    length_metres = length * metres_per_unit
186    angle_rad = angle * np.pi / 180
187
188    # End-point of bar.
189    end = _point_along_line(ax, location, length_metres, angle=angle_rad,
190                            tol=tol)
191
192    # Coordinates are currently in axes coordinates, so use transAxes to
193    # put into data coordinates. *zip(a, b) produces a list of x-coords,
194    # then a list of y-coords.
195    ax.plot(*zip(location, end), transform=ax.transAxes, **plot_kwargs)
196
197    # Push text away from bar in the perpendicular direction.
198    midpoint = (location + end) / 2
199    offset = text_offset * np.array([-np.sin(angle_rad), np.cos(angle_rad)])
200    text_location = midpoint + offset
201
202    # 'rotation' keyword argument is in text_kwargs.
203    ax.text(*text_location, f"{length} {unit_name}", rotation_mode='anchor',
204            transform=ax.transAxes, **text_kwargs)

Add a scale bar to CartoPy axes.

For angles between 0 and 90 the text and line may be plotted at slightly different angles for unknown reasons. To work around this, override the 'rotation' keyword argument with text_kwargs.

Parameters
  • ax (): CartoPy axes
  • location (): Position of left-side of bar in axes coordinates.
  • length (): Geodesic length of the scale bar.
  • metres_per_unit (default=1000): Number of metres in the given unit.
  • unit_name (str, default='km'): Name of the given unit.
  • tol (float, default=0.01): Allowed relative error in length of bar
  • angle (float): Anti-clockwise rotation of the bar.
  • color (str, default='black'): Color of the bar and text.
  • linewidth (float): Same argument as for plot.
  • text_offset (float, default=0.005): Perpendicular offset for text in axes coordinates.
  • ha (str or float [0-1], default='center'): Horizontal alignment.
  • va (str or float [0-1], default='bottom'): Vertical alignment.
  • plot_kwargs (dict): Keyword arguments for plot, overridden by **kwargs.
  • text_kwargs (dict): Keyword arguments for text, overridden by **kwargs.
  • **kwargs (): Keyword arguments for both plot and text.