import matplotlib.colors
import matplotlib.pyplot as plt
import numpy as np
from typing import Tuple, List, Union
[docs]
def calculate_ticks(
extent: Union[Tuple[float, ...], List[float]],
direction: str,
decimal_degrees: bool = False,
whole_degree_majors: bool = True,
) -> Tuple[np.ndarray, np.ndarray, Union[np.ndarray, List[str]]]:
"""
Define major and minor tick locations and major tick labels
Args:
extent (tuple or list): extent (x0, x1, y0, y1) of the map in the given coordinate system.
direction (str): Tell function which bounds to calculate. Must be 'longitude' or 'latitude'.
decimal_degrees (bool, optional): label ticks in decimal degrees (True) or degree-minute-second (False, default)
whole_degree_majors (bool, optional): keep major ticks on whole degrees even for small extents
(span <= 3 degrees), with minutes only ever shown on minor ticks. Defaults to True. Set to False
to allow major ticks at 15'/30' increments for spans <= 3 degrees, matching pre-1.x behavior.
Returns:
np.ndarray: minor ticks
np.ndarray: major ticks
Union[np.ndarray, List[str]]: major tick labels
"""
# Lowercase the direction variable
direction = direction.lower()
# Convert values in extent to floats
extent = [float(x) for x in extent]
if direction == 'longitude':
l0, l1 = extent[0], extent[1]
elif direction == 'latitude':
l0, l1 = extent[2], extent[3]
else:
raise ValueError("direction must be 'longitude' or 'latitude'")
# Calculate distance
r = l1 - l0
# Pre-defined tick spacing based off of distance in degrees
if not whole_degree_majors and r <= 1.5:
# <1.5 degrees: 15' major ticks, 5' minor ticks
minor_int = 1.0 / 12.0
major_int = 1.0 / 4.0
elif not whole_degree_majors and r <= 3.0:
# <3 degrees: 30' major ticks, 10' minor ticks
minor_int = 1.0 / 6.0
major_int = 0.5
elif r <= 7.0:
# <=7 degrees: 1d major ticks, 15' minor ticks
minor_int = 0.25
major_int = 1.0
elif r <= 10:
# <=10 degrees: 2d major ticks, 30' minor ticks
minor_int = 0.5
major_int = 2.0
elif r <= 50:
# <=50 degrees: 5d major ticks, 1d minor ticks
minor_int = 1.0
major_int = 5.0
elif r <= 80:
# <=80 degrees: 10d major ticks, 5d minor ticks
minor_int = 5.0
major_int = 10.0
elif r <= 120:
# <=120 degrees: 15d major ticks, 5d minor ticks
minor_int = 5.0
major_int = 15.0
elif r <= 160:
# <=160 degrees: 20d major ticks, 5d minor ticks
minor_int = 5.0
major_int = 20.0
elif r <= 250:
# <=250 degrees: 30d major ticks, 10d minor ticks
minor_int = 10.0
major_int = 30.0
else:
# >250 degrees: 45d major ticks, 15d minor ticks
minor_int = 15.0
major_int = 45.0
minor_ticks = np.arange(
np.ceil(l0 / minor_int) * minor_int,
np.ceil(l1 / minor_int) * minor_int + minor_int,
minor_int
)
minor_ticks = minor_ticks[minor_ticks <= l1]
major_ticks = np.arange(
np.ceil(l0 / major_int) * major_int,
np.ceil(l1 / major_int) * major_int + major_int,
major_int
)
major_ticks = major_ticks[major_ticks <= l1]
label_int = major_int
if major_ticks.size == 0:
# Extent is narrower than one major interval and doesn't straddle a
# major tick value (e.g. a very tight zoom); fall back to minor ticks
# so the map is never left with zero labeled ticks.
major_ticks = minor_ticks
label_int = minor_int
if decimal_degrees:
major_tick_labels = major_ticks
else:
if label_int < 1:
d, m, _ = dd2dms(np.array(major_ticks))
if direction == 'longitude':
dirs = np.where(d < 0, 'W', 'E')
major_tick_labels = [f"{int(abs(d[i]))}\N{DEGREE SIGN}{int(m[i])}'{dirs[i]}" for i in range(len(d))]
elif direction == 'latitude':
dirs = np.where(d < 0, 'S', 'N')
major_tick_labels = [f"{int(abs(d[i]))}\N{DEGREE SIGN}{int(m[i])}'{dirs[i]}" for i in range(len(d))]
else:
major_tick_labels = [f"{int(d[i])}\N{DEGREE SIGN}{int(m[i])}'" for i in range(len(d))]
else:
d = major_ticks
if direction == 'longitude':
dirs = np.where(d < 0, 'W', 'E')
major_tick_labels = [f"{int(abs(d[i]))}\N{DEGREE SIGN}{dirs[i]}" for i in range(len(d))]
elif direction == 'latitude':
dirs = np.where(d < 0, 'S', 'N')
major_tick_labels = [f"{int(abs(d[i]))}\N{DEGREE SIGN}{dirs[i]}" for i in range(len(d))]
else:
major_tick_labels = [f"{int(d[i])}\N{DEGREE SIGN}" for i in range(len(d))]
return minor_ticks, major_ticks, major_tick_labels
[docs]
def pad_extent(
extent: Union[Tuple[float, ...], List[float]],
padding: Union[float, Tuple[float, float], List[float]],
) -> Tuple[float, float, float, float]:
"""
Expand an extent outward by a padding amount so map ticks and data don't
land right on the edge of the map.
Args:
extent (tuple or list): extent (x0, x1, y0, y1) to pad.
padding (float or tuple/list of 2 floats): degrees to subtract from x0/y0
and add to x1/y1. A single number pads longitude and latitude equally;
a 2-item sequence is (lon_padding, lat_padding).
Returns:
tuple: padded extent (x0, x1, y0, y1)
"""
x0, x1, y0, y1 = (float(v) for v in extent)
if isinstance(padding, (tuple, list, np.ndarray)):
if len(padding) != 2:
raise ValueError("padding sequence must have exactly 2 values: (lon_padding, lat_padding)")
lon_pad, lat_pad = float(padding[0]), float(padding[1])
else:
lon_pad = lat_pad = float(padding)
return (x0 - lon_pad, x1 + lon_pad, y0 - lat_pad, y1 + lat_pad)
[docs]
def calculate_colorbar_ticks(vmin: float, vmax: float, c0: bool = False) -> np.ndarray:
"""
Calculate tick locations for colorbar
Args:
vmin (float): minimum value of colorbar (should match vmin of object colorbar is mapped to)
vmax (float): maximum value of colorbar (should match vmax of object colorbar is mapped to)
c0 (bool): center values around 0 (for anomaly products)
Returns:
np.ndarray: tick locations
"""
if c0:
vmax = np.max((np.abs(vmin), np.abs(vmax)))
vmin = 0
scale = 1.0
if vmax - vmin < 0:
scale = 10**(-np.floor(np.log10(vmax - vmin)) + 1)
cbticks = np.arange(vmin, np.floor(vmax * scale + .99))
if len(cbticks) > 3:
cbticks = cbticks[::int(np.floor(len(cbticks) / 3))]
cbticks = cbticks / scale
i = np.diff(cbticks)[0]
if cbticks[-1] + i <= vmax:
cbticks = np.append(cbticks, cbticks[-1] + i)
i = np.diff(cbticks)[0]
cbticks = np.append(np.arange(-np.max(cbticks), 0, i), cbticks)
return cbticks
scale = 1.0
if vmax - vmin < 4:
scale = 10**(-np.floor(np.log10(vmax - vmin)) + 1)
cbticks = np.arange(np.ceil(vmin * scale + .01), np.floor(vmax * scale + .99))
if len(cbticks) > 5:
cbticks = cbticks[::int(np.floor(len(cbticks) / 5))]
cbticks = cbticks / scale
i = np.diff(cbticks)[0]
if cbticks[0] - i >= vmin:
cbticks = np.append(cbticks[0] - i, cbticks)
if cbticks[-1] + i <= vmax:
cbticks = np.append(cbticks, cbticks[-1] + i)
return cbticks
[docs]
def dd2dms(vals: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Convert decimal degrees to degree-minute-second
Args:
vals (np.ndarray): Numpy array of decimal degrees
Returns:
np.ndarray: degrees
np.ndarray: minutes
np.ndarray: seconds
"""
n = vals < 0
vals_abs = np.abs(vals)
d = np.floor(vals_abs)
rem = (vals_abs - d) * 60.0
m = np.floor(rem)
rem -= m
s = np.round(rem * 60.0)
# Apply sign back to degrees
d[n] = -d[n]
return d, m, s
[docs]
def fmt(x: float) -> str:
"""
Function to format bathymetry labels
Args:
x (float): Bathymetry float value
Returns:
str: Formatted string
"""
s = f"{x:.1f}"
if s.endswith("0"):
s = f"{x:.0f}"
return rf"{s}"