src/metpy/plots/patheffects.py
# Copyright (c) 2020,2022,2023 MetPy Developers.
# Distributed under the terms of the BSD 3-Clause License.
# SPDX-License-Identifier: BSD-3-Clause
"""Add effects to matplotlib paths."""
from functools import cached_property
import itertools
import matplotlib.colors as mcolors
import matplotlib.path as mpath
import matplotlib.patheffects as mpatheffects
import matplotlib.transforms as mtransforms
import numpy as np
from ..package_tools import Exporter
exporter = Exporter(globals())
class Front(mpatheffects.AbstractPathEffect):
"""Provide base functionality for plotting fronts as a patheffect.
These are plotted as symbol markers tangent to the path.
"""
_symbol = mpath.Path([[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]],
[mpath.Path.MOVETO, mpath.Path.LINETO,
mpath.Path.LINETO, mpath.Path.LINETO, mpath.Path.CLOSEPOLY])
def __init__(self, color, size=10, spacing=1, flip=False, filled=True):
"""Initialize the front path effect.
Parameters
----------
color : str or tuple[float]
Color to use for the effect.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbol should be flipped to the other side of the path. Defaults
to `False`.
filled : bool
Whether the symbol should be filled with the color. Defaults to `True`.
"""
super().__init__()
self.size = size
self.spacing = spacing
self.color = mcolors.to_rgba(color)
self.flip = flip
self.filled = filled
self._symbol_width = None
@cached_property
def symbol_width(self):
"""Return the width of the symbol being plotted."""
return self._symbol.get_extents().width
def _step_size(self, renderer):
"""Return the length of the step between markers in pixels."""
return (self.symbol_width + self.spacing) * self._size_pixels(renderer)
def _size_pixels(self, renderer):
"""Return the size of the marker in pixels."""
return renderer.points_to_pixels(self.size)
@staticmethod
def _process_path(path, path_trans):
"""Transform the main path into pixel coordinates; calculate the needed components."""
path_points = path.transformed(path_trans).interpolated(500).vertices
deltas = (path_points[1:] - path_points[:-1]).T
pt_offsets = np.concatenate(([0], np.hypot(*deltas).cumsum()))
angles = np.arctan2(deltas[-1], deltas[0])
return path_points, pt_offsets, angles
def _get_marker_locations(self, segment_offsets, renderer):
# Calculate increment of path length occupied by each marker drawn
inc = self._step_size(renderer)
# Find out how many markers that will accommodate, as well as remainder space
num, leftover = divmod(segment_offsets[-1], inc)
# Find the offset for each marker along the path length. We center along
# the path by adding half of the remainder. The offset is also centered within
# the marker by adding half of the marker increment
marker_offsets = np.arange(num) * inc + (leftover + inc) / 2.
# Find the location of these offsets within the total offset within each
# path segment; subtracting 1 gives us the left point of the path rather
# than the last. We then need to adjust for any offsets that are <= the first
# point of the path (just set them to index 0).
inds = np.searchsorted(segment_offsets, marker_offsets) - 1
inds[inds < 0] = 0
# Return the indices to the proper segment and the offset within that segment
return inds, marker_offsets - segment_offsets[inds]
def _override_gc(self, renderer, gc, **kwargs):
ret = renderer.new_gc()
ret.copy_properties(gc)
ret.set_joinstyle('miter')
ret.set_capstyle('butt')
return self._update_gc(ret, kwargs)
def _get_symbol_transform(self, renderer, offset, line_shift, angle, start):
scalex = self._size_pixels(renderer)
scaley, line_shift = (-scalex, -line_shift) if self.flip else (scalex, line_shift)
return mtransforms.Affine2D().scale(scalex, scaley).translate(
offset - self.symbol_width * self._size_pixels(renderer) / 2,
line_shift).rotate(angle).translate(*start)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
# Set up a new graphics context for rendering the front effect; override the color
gc0 = self._override_gc(renderer, gc, foreground=self.color)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon and how
# far within that segment the markers will appear.
segment_indices, marker_offsets = self._get_marker_locations(offsets, renderer)
# Draw the original path
renderer.draw_path(gc0, path, affine, rgbFace) # noqa: N803
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the markers to draw
for ind, marker_offset in zip(segment_indices, marker_offsets, strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
renderer.draw_path(gc0, self._symbol, sym_trans,
self.color if self.filled else None)
gc0.restore()
class Frontogenesis(Front):
"""Provide base functionality for plotting strengthening fronts as a patheffect.
These are plotted as symbol markers tangent to the path.
"""
def __init__(self, color, size=10, spacing=1, flip=False):
"""Initialize the frontogenesis path effect.
Parameters
----------
color : str or tuple[float]
Color to use for the effect.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbol should be flipped to the other side of the path. Defaults
to `False`.
"""
super().__init__(color, size, spacing, flip)
self._padding = 4
def _step_size(self, renderer):
"""Return the length of the step between markers in pixels."""
return (self.symbol_width + self.spacing + self._padding) * self._size_pixels(renderer)
def _get_path_locations(self, segment_offsets, renderer):
# Calculate increment of path length occupied by each marker drawn
inc = self._step_size(renderer)
# Find out how many markers that will accommodate, as well as remainder space
num, leftover = divmod(segment_offsets[-1], inc)
# Find the offset for each marker along the path length. We center along
# the path by adding half of the remainder. The offset is also centered within
# the marker by adding half of the marker increment
marker_offsets = np.arange(num) * inc + (leftover + inc) / 2.
# Do the same for path segments
start_offsets = marker_offsets - 0.33 * inc
end_offsets = marker_offsets + 0.33 * inc
# Find the location of these offsets within the total offset within each
# path segment; subtracting 1 gives us the left point of the path rather
# than the last. We then need to adjust for any offsets that are <= the first
# point of the path (just set them to index 0).
inds = np.searchsorted(segment_offsets, marker_offsets) - 1
inds[inds < 0] = 0
start_inds = np.searchsorted(segment_offsets, start_offsets) - 1
start_inds[start_inds < 0] = 0
end_inds = np.searchsorted(segment_offsets, end_offsets) - 1
end_inds[start_inds < 0] = 0
# Return the indices to the proper segment and the offset within that segment
return start_inds, end_inds, inds, marker_offsets - segment_offsets[inds]
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
# Set up a new graphics context for rendering the front effect; override the color
gc0 = self._override_gc(renderer, gc, foreground=self.color)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon, how
# far within that segment the markers will appear, and the segment bounds.
(segment_starts, segment_ends,
segment_indices, marker_offsets) = self._get_path_locations(offsets, renderer)
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the segments to draw
for start_path, end_path in zip(segment_starts, segment_ends, strict=False):
renderer.draw_path(gc0, mpath.Path(starts[start_path:end_path]),
mtransforms.Affine2D(), None)
# Loop over all the markers to draw
for ind, marker_offset in zip(segment_indices, marker_offsets, strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
renderer.draw_path(gc0, self._symbol, sym_trans, self.color)
gc0.restore()
class Frontolysis(Front):
"""Provide base functionality for plotting weakening fronts as a patheffect.
These are plotted as symbol markers tangent to the path.
"""
def __init__(self, color, size=10, spacing=1, flip=False):
"""Initialize the frontolysis path effect.
Parameters
----------
color : str or tuple[float]
Color to use for the effect.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbol should be flipped to the other side of the path. Defaults
to `False`.
"""
super().__init__(color, size, spacing, flip)
self._padding = 4
def _step_size(self, renderer):
"""Return the length of the step between markers in pixels."""
return (self.symbol_width + self.spacing + self._padding) * self._size_pixels(renderer)
def _get_path_locations(self, segment_offsets, renderer):
# Calculate increment of path length occupied by each marker drawn
inc = self._step_size(renderer)
# Find out how many markers that will accommodate, as well as remainder space
num, leftover = divmod(segment_offsets[-1], inc)
# Find the offset for each marker along the path length. We center along
# the path by adding half of the remainder. The offset is also centered within
# the marker by adding half of the marker increment
marker_offsets = np.arange(num) * inc + (leftover + inc) / 2.
# Do the same for path segments
start_offsets = marker_offsets - 0.33 * inc
end_offsets = marker_offsets + 0.33 * inc
# Find the location of these offsets within the total offset within each
# path segment; subtracting 1 gives us the left point of the path rather
# than the last. We then need to adjust for any offsets that are <= the first
# point of the path (just set them to index 0).
inds = np.searchsorted(segment_offsets, marker_offsets) - 1
inds[inds < 0] = 0
start_inds = np.searchsorted(segment_offsets, start_offsets) - 1
start_inds[start_inds < 0] = 0
end_inds = np.searchsorted(segment_offsets, end_offsets) - 1
end_inds[start_inds < 0] = 0
# Return the indices to the proper segment and the offset within that segment
return start_inds, end_inds, inds, marker_offsets - segment_offsets[inds]
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
# Set up a new graphics context for rendering the front effect; override the color
gc0 = self._override_gc(renderer, gc, foreground=self.color)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon, how
# far within that segment the markers will appear, and the segment bounds.
(segment_starts, segment_ends,
segment_indices, marker_offsets) = self._get_path_locations(offsets, renderer)
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the segments to draw
for start_path, end_path in zip(segment_starts, segment_ends, strict=False):
renderer.draw_path(gc0, mpath.Path(starts[start_path:end_path]),
mtransforms.Affine2D(), None)
# Loop over all the markers to draw
for ind, marker_offset in zip(segment_indices[::2], marker_offsets[::2], strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
renderer.draw_path(gc0, self._symbol, sym_trans, self.color)
gc0.restore()
@exporter.export
class ScallopedStroke(mpatheffects.AbstractPathEffect):
"""A line-based PathEffect which draws a path with a scalloped style.
The spacing, length, and side of the scallops can be controlled. This implementation is
based off of :class:`matplotlib.patheffects.TickedStroke`.
"""
def __init__(self, offset=(0, 0), spacing=10.0, side='left', length=1.15, **kwargs):
"""Create a scalloped path effect.
Parameters
----------
offset : (float, float)
The (x, y) offset to apply to the path, in points. Defaults to no offset.
spacing : float
The spacing between ticks in points. Defaults to 10.0.
side : str
Side of the path scallops appear on from the reference of
walking along the curve. Options are left and right. Defaults to ``'left'``.
length : float
The length of the tick relative to spacing. Defaults to 1.414.
kwargs :
Extra keywords are stored and passed through to
`~matplotlib.renderer.GraphicsContextBase`.
"""
super().__init__(offset)
self._spacing = spacing
if side == 'left':
self._angle = 90
elif side == 'right':
self._angle = -90
else:
raise ValueError('Side must be left or right.')
self._length = length
self._gc = kwargs
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the path with updated gc."""
# Do not modify the input! Use copy instead.
gc0 = renderer.new_gc()
gc0.copy_properties(gc)
gc0 = self._update_gc(gc0, self._gc)
trans = affine + self._offset_transform(renderer)
theta = -np.radians(self._angle)
trans_matrix = np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])
# Convert spacing parameter to pixels.
spacing_px = renderer.points_to_pixels(self._spacing)
# Transform before evaluation because to_polygons works at resolution
# of one -- assuming it is working in pixel space.
transpath = affine.transform_path(path)
# Evaluate path to straight line segments that can be used to
# construct line scallops.
polys = transpath.to_polygons(closed_only=False)
for p in polys:
x = p[:, 0]
y = p[:, 1]
# Can not interpolate points or draw line if only one point in
# polyline.
if x.size < 2:
continue
# Find distance between points on the line
ds = np.hypot(x[1:] - x[:-1], y[1:] - y[:-1])
# Build parametric coordinate along curve
s = np.concatenate(([0.0], np.cumsum(ds)))
s_total = s[-1]
num = int(np.ceil(s_total / spacing_px)) - 1
# Pick parameter values for scallops.
s_tick = np.linspace(0, s_total, num)
# Find points along the parameterized curve
x_tick = np.interp(s_tick, s, x)
y_tick = np.interp(s_tick, s, y)
# Find unit vectors in local direction of curve
delta_s = self._spacing * .001
u = (np.interp(s_tick + delta_s, s, x) - x_tick) / delta_s
v = (np.interp(s_tick + delta_s, s, y) - y_tick) / delta_s
# Handle slope of end point
if (x_tick[-1], y_tick[-1]) == (x_tick[0], y_tick[0]): # periodic
u[-1] = u[0]
v[-1] = v[0]
else:
u[-1] = u[-2]
v[-1] = v[-2]
# Normalize slope into unit slope vector.
n = np.hypot(u, v)
mask = n == 0
n[mask] = 1.0
uv = np.array([u / n, v / n]).T
uv[mask] = np.array([0, 0]).T
# Rotate and scale unit vector
dxy = np.dot(uv, trans_matrix) * self._length * spacing_px
# Build endpoints
x_end = x_tick + dxy[:, 0]
y_end = y_tick + dxy[:, 1]
# Interleave ticks to form Path vertices
xyt = np.empty((2 * num, 2), dtype=x_tick.dtype)
xyt[0::2, 0] = x_tick
xyt[1::2, 0] = x_end
xyt[0::2, 1] = y_tick
xyt[1::2, 1] = y_end
# Build path vertices that will define control points of the bezier curves
verts = []
i = 0
nverts = 0
while i < len(xyt) - 2:
verts.append(xyt[i, :])
verts.append(xyt[i + 1, :])
verts.append(xyt[i + 3, :])
verts.append(xyt[i + 2, :])
nverts += 1
i += 2
# Build up vector of Path codes
codes = np.tile([mpath.Path.LINETO, mpath.Path.CURVE4,
mpath.Path.CURVE4, mpath.Path.CURVE4], nverts)
codes[0] = mpath.Path.MOVETO
# Construct and draw resulting path
h = mpath.Path(verts, codes)
# Transform back to data space during render
renderer.draw_path(gc0, h, affine.inverted() + trans, rgbFace)
gc0.restore()
@exporter.export
class ColdFront(Front):
"""Draw a path as a cold front, with (default blue) pips/triangles along the path."""
_symbol = mpath.Path([[0, 0], [1, 1], [2, 0], [0, 0]],
[mpath.Path.MOVETO, mpath.Path.LINETO, mpath.Path.LINETO,
mpath.Path.CLOSEPOLY])
def __init__(self, color='blue', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class ColdFrontogenesis(Frontogenesis):
"""Draw a path as a strengthening cold."""
_symbol = mpath.Path([[0, 0], [1, 1], [2, 0], [0, 0]],
[mpath.Path.MOVETO, mpath.Path.LINETO, mpath.Path.LINETO,
mpath.Path.CLOSEPOLY])
def __init__(self, color='blue', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class ColdFrontolysis(Frontolysis):
"""Draw a path as a weakening cold front."""
_symbol = mpath.Path([[0, 0], [1, 1], [2, 0], [0, 0]],
[mpath.Path.MOVETO, mpath.Path.LINETO, mpath.Path.LINETO,
mpath.Path.CLOSEPOLY])
def __init__(self, color='blue', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class Dryline(Front):
"""Draw a path as a dryline with (default brown) scallops along the path."""
_symbol = mpath.Path.wedge(0, 180).transformed(mtransforms.Affine2D().translate(1, 0))
def __init__(self, color='brown', spacing=0.144, filled=False, **kwargs):
super().__init__(color, spacing=spacing, filled=filled, **kwargs)
@exporter.export
class WarmFront(Front):
"""Draw a path as a warm front with (default red) scallops along the path."""
_symbol = mpath.Path.wedge(0, 180).transformed(mtransforms.Affine2D().translate(1, 0))
def __init__(self, color='red', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class WarmFrontogenesis(Frontogenesis):
"""Draw a path as a strengthening warm front."""
_symbol = mpath.Path.wedge(0, 180).transformed(mtransforms.Affine2D().translate(1, 0))
def __init__(self, color='red', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class WarmFrontolysis(Frontolysis):
"""Draw a path as a weakening warm front."""
_symbol = mpath.Path.wedge(0, 180).transformed(mtransforms.Affine2D().translate(1, 0))
def __init__(self, color='red', **kwargs):
super().__init__(color, **kwargs)
@exporter.export
class OccludedFront(Front):
"""Draw an occluded front with (default purple) pips and scallops along the path."""
def __init__(self, color='purple', **kwargs):
self._symbol_cycle = None
super().__init__(color, **kwargs)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
self._symbol_cycle = None
return super().draw_path(renderer, gc, path, affine, rgbFace) # noqa: N803
@property
def _symbol(self):
"""Return the proper symbol to draw; alternatives between scallop and pip/triangle."""
if self._symbol_cycle is None:
self._symbol_cycle = itertools.cycle([WarmFront._symbol, ColdFront._symbol])
return next(self._symbol_cycle)
@exporter.export
class OccludedFrontogenesis(Frontogenesis):
"""Draw a strengthening occluded front."""
def __init__(self, color='purple', **kwargs):
self._symbol_cycle = None
super().__init__(color, **kwargs)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
self._symbol_cycle = None
return super().draw_path(renderer, gc, path, affine, rgbFace) # noqa: N803
@property
def _symbol(self):
"""Return the proper symbol to draw; alternatives between scallop and pip/triangle."""
if self._symbol_cycle is None:
self._symbol_cycle = itertools.cycle([WarmFrontogenesis._symbol,
ColdFrontogenesis._symbol])
return next(self._symbol_cycle)
@exporter.export
class OccludedFrontolysis(Frontolysis):
"""Draw a weakening occluded front."""
def __init__(self, color='purple', **kwargs):
self._symbol_cycle = None
super().__init__(color, **kwargs)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
self._symbol_cycle = None
return super().draw_path(renderer, gc, path, affine, rgbFace) # noqa: N803
@property
def _symbol(self):
"""Return the proper symbol to draw; alternatives between scallop and pip/triangle."""
if self._symbol_cycle is None:
self._symbol_cycle = itertools.cycle([WarmFrontolysis._symbol,
ColdFrontolysis._symbol])
return next(self._symbol_cycle)
@exporter.export
class RidgeAxis(mpatheffects.AbstractPathEffect):
"""A line-based PathEffect which draws a path with a sawtooth-wave style.
This line style is frequently used to represent a ridge axis.
"""
def __init__(self, color='black', spacing=12.0, length=0.5):
"""Create ridge axis path effect.
Parameters
----------
color : str
Color to use for the effect.
spacing : float
The spacing between ticks in points. Default is 12.
length : float
The length of the tick relative to spacing. Default is 0.5.
"""
self._spacing = spacing
self._angle = 90.0
self._length = length
self._color = color
def _override_gc(self, renderer, gc, **kwargs):
ret = renderer.new_gc()
ret.copy_properties(gc)
ret.set_joinstyle('miter')
ret.set_capstyle('butt')
return self._update_gc(ret, kwargs)
def draw_path(self, renderer, gc, tpath, affine, rgbFace): # noqa: N803
"""Draw the path with updated gc."""
# Do not modify the input! Use copy instead.
gc0 = self._override_gc(renderer, gc, foreground=self._color)
theta = -np.radians(self._angle)
trans_matrix = np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])
# Convert spacing parameter to pixels.
spacing_px = renderer.points_to_pixels(self._spacing)
# Transform before evaluation because to_polygons works at resolution
# of one -- assuming it is working in pixel space.
transpath = affine.transform_path(tpath)
# Evaluate path to straight line segments that can be used to
# construct line ticks.
polys = transpath.to_polygons(closed_only=False)
for p in polys:
x = p[:, 0]
y = p[:, 1]
# Can not interpolate points or draw line if only one point in
# polyline.
if x.size < 2:
continue
# Find distance between points on the line
ds = np.hypot(x[1:] - x[:-1], y[1:] - y[:-1])
# Build parametric coordinate along curve
s = np.concatenate(([0.0], np.cumsum(ds)))
s_total = s[-1]
num = int(np.ceil(s_total / spacing_px)) - 1
# Pick parameter values for ticks.
s_tick = np.linspace(spacing_px / 2, s_total - spacing_px / 2, num)
# Find points along the parameterized curve
x_tick = np.interp(s_tick, s, x)
y_tick = np.interp(s_tick, s, y)
# Find unit vectors in local direction of curve
delta_s = self._spacing * .001
u = (np.interp(s_tick + delta_s, s, x) - x_tick) / delta_s
v = (np.interp(s_tick + delta_s, s, y) - y_tick) / delta_s
# Normalize slope into unit slope vector.
n = np.hypot(u, v)
mask = n == 0
n[mask] = 1.0
uv = np.array([u / n, v / n]).T
uv[mask] = np.array([0, 0]).T
# Rotate and scale unit vector into tick vector
dxy1 = np.dot(uv[0::2], trans_matrix) * self._length * spacing_px
dxy2 = np.dot(uv[1::2], trans_matrix.T) * self._length * spacing_px
# Build tick endpoints
x_end = np.zeros(num)
y_end = np.zeros(num)
x_end[0::2] = x_tick[0::2] + dxy1[:, 0]
x_end[1::2] = x_tick[1::2] + dxy2[:, 0]
y_end[0::2] = y_tick[0::2] + dxy1[:, 1]
y_end[1::2] = y_tick[1::2] + dxy2[:, 1]
# Interleave ticks to form Path vertices
xyt = np.empty((num, 2), dtype=x_tick.dtype)
xyt[:, 0] = x_end
xyt[:, 1] = y_end
# Build up vector of Path codes
codes = np.concatenate([[mpath.Path.MOVETO], [mpath.Path.LINETO] * (len(xyt) - 1)])
# Construct and draw resulting path
h = mpath.Path(xyt, codes)
# Transform back to data space during render
renderer.draw_path(gc0, h, affine.inverted() + affine, rgbFace) # noqa: N803
gc0.restore()
@exporter.export
class Squall(mpatheffects.AbstractPathEffect):
"""Squall line path effect."""
symbol = mpath.Path.circle((0, 0), radius=4)
def __init__(self, color='black', spacing=75):
"""Initialize the squall line path effect.
Parameters
----------
color : str
Color to use for the effect.
spacing : float
Spacing between symbols along path (in points).
"""
self.marker_margin = 10
self.spacing = spacing
self.color = mcolors.to_rgba(color)
self._symbol_width = None
@staticmethod
def _process_path(path, path_trans):
"""Transform the main path into pixel coordinates; calculate the needed components."""
path_points = path.transformed(path_trans).interpolated(500).vertices
deltas = (path_points[1:] - path_points[:-1]).T
pt_offsets = np.concatenate(([0], np.hypot(*deltas).cumsum()))
return path_points, pt_offsets
def _override_gc(self, renderer, gc, **kwargs):
ret = renderer.new_gc()
ret.copy_properties(gc)
ret.set_joinstyle('miter')
ret.set_capstyle('butt')
return self._update_gc(ret, kwargs)
def _get_object_locations(self, segment_offsets, renderer):
# Calculate increment of path length
inc = renderer.points_to_pixels(self.spacing)
margin = renderer.points_to_pixels(self.marker_margin)
# Find out how many markers that will accommodate, as well as remainder space
num, leftover = divmod(segment_offsets[-1], inc)
# Find the offset for each marker along the path length. We center along
# the path by adding half of the remainder. The offset is also centered within
# the marker by adding half of the marker increment
first_marker = np.arange(num) * inc - 0.5 * margin + (leftover + inc) / 2.
second_marker = np.arange(num) * inc + 0.5 * margin + (leftover + inc) / 2.
marker_offsets = np.sort(np.concatenate([first_marker, second_marker]))
# Do the same for path segments
first = segment_offsets[0]
last = segment_offsets[-1]
path_offset_1 = np.arange(num) * inc - 1.5 * margin + (leftover + inc) / 2
path_offset_2 = np.arange(num) * inc + 1.5 * margin + (leftover + inc) / 2
path_offsets = np.sort(np.concatenate(
[[first], path_offset_1, path_offset_2, [last]]
))
# Find the location of these offsets within the total offset within each
# path segment; subtracting 1 gives us the left point of the path rather
# than the last. We then need to adjust for any offsets that are <= the first
# point of the path (just set them to index 0).
marker_inds = np.searchsorted(segment_offsets, marker_offsets) - 1
marker_inds[marker_inds < 0] = 0
# Do the same for path segments
path_inds = np.searchsorted(segment_offsets, path_offsets) - 1
path_inds[path_inds < 0] = 0
# Return the indices to the proper segment and the offset within that segment
return marker_inds, path_inds
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw path."""
# Set up a new graphics context for rendering the front effect; override the color
gc0 = self._override_gc(renderer, gc, foreground=self.color)
# Get the information we need for drawing along the path
starts, offsets = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon and how
# far within that segment the markers will appear.
marker_indices, path_indices = self._get_object_locations(offsets, renderer)
base_trans = mtransforms.Affine2D()
# Loop over the segmented path
ipath = path.interpolated(500).vertices
for i in range(0, len(path_indices) - 1, 2):
start = path_indices[i]
stop = path_indices[i + 1]
n = stop - start
spath = mpath.Path(
ipath[start:stop],
[mpath.Path.MOVETO] + [mpath.Path.LINETO] * (n - 1)
)
renderer.draw_path(gc0, spath, affine, None)
# Loop over all the markers to draw
for ind in marker_indices:
sym_trans = base_trans.frozen().translate(*starts[ind])
renderer.draw_path(gc0, self.symbol, sym_trans, self.color)
gc0.restore()
@exporter.export
class StationaryFront(Front):
"""Draw a stationary front as alternating cold and warm front segments."""
_symbol = WarmFront._symbol.transformed(mtransforms.Affine2D().scale(1, -1))
_symbol2 = ColdFront._symbol
def __init__(self, colors=('red', 'blue'), **kwargs):
"""Initialize a stationary front path effect.
This effect alternates between a warm front and cold front symbol.
Parameters
----------
colors : Sequence[str] or Sequence[tuple[float]]
Matplotlib color identifiers to cycle between on the two different front styles.
Defaults to alternating red and blue.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbols should be flipped to the other side of the path. Defaults
to `False`.
"""
self._colors = list(map(mcolors.to_rgba, colors))
super().__init__(color=self._colors[0], **kwargs)
def _get_path_segment_ends(self, segment_offsets, renderer):
# Calculate increment of path length occupied by each marker drawn
inc = self._step_size(renderer)
# Find out how many markers that will accommodate, as well as remainder space
num, leftover = divmod(segment_offsets[-1], inc)
# Find the offset for each path segment end. We center along
# the entire path by adding half of the remainder.
path_offsets = np.arange(1, num + 1) * inc + leftover / 2.
# Find the location of these offsets within the total offset within each
# path segment; subtracting 1 gives us the left point of the path rather
# than the last.
return np.searchsorted(segment_offsets, path_offsets)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
gcs = [self._override_gc(renderer, gc, foreground=color) for color in self._colors]
self._gc_cycle = itertools.cycle(gcs)
self._symbol_cycle = itertools.cycle([self._symbol, self._symbol2])
self._color_cycle = itertools.cycle(self._colors)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon and how
# far within that segment the markers will appear.
segment_indices, marker_offsets = self._get_marker_locations(offsets, renderer)
end_path_inds = self._get_path_segment_ends(offsets, renderer)
start_path_inds = np.concatenate([[0], end_path_inds[:-1]])
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = -renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the markers to draw
for ind, start_path, end_path, marker_offset in zip(segment_indices, start_path_inds,
end_path_inds, marker_offsets,
strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
gc = next(self._gc_cycle)
color = next(self._color_cycle)
symbol = next(self._symbol_cycle)
renderer.draw_path(gc, symbol, sym_trans, color)
renderer.draw_path(gc, mpath.Path(starts[start_path:end_path]),
mtransforms.Affine2D(), None)
line_shift *= -1
gcs[0].restore()
@exporter.export
class StationaryFrontogenesis(Frontogenesis):
"""Draw a strengthening stationary front."""
_symbol = WarmFront._symbol
_symbol2 = ColdFront._symbol.transformed(mtransforms.Affine2D().scale(1, -1))
def __init__(self, colors=('red', 'blue'), **kwargs):
"""Initialize a strengthening stationary front path effect.
This effect alternates between a warm front and cold front symbol.
Parameters
----------
colors : Sequence[str] or Sequence[tuple[float]]
Matplotlib color identifiers to cycle between on the two different front styles.
Defaults to alternating red and blue.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbols should be flipped to the other side of the path. Defaults
to `False`.
"""
self._colors = list(map(mcolors.to_rgba, colors))
super().__init__(color=self._colors[0], **kwargs)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
gcs = [self._override_gc(renderer, gc, foreground=color) for color in self._colors]
self._gc_cycle = itertools.cycle(gcs)
self._symbol_cycle = itertools.cycle([self._symbol, self._symbol2])
self._color_cycle = itertools.cycle(self._colors)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon, how
# far within that segment the markers will appear, and the segment bounds.
(segment_starts, segment_ends,
segment_indices, marker_offsets) = self._get_path_locations(offsets, renderer)
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the markers to draw
for ind, start_path, end_path, marker_offset in zip(segment_indices, segment_starts,
segment_ends, marker_offsets,
strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
gc = next(self._gc_cycle)
color = next(self._color_cycle)
symbol = next(self._symbol_cycle)
renderer.draw_path(gc, symbol, sym_trans, color)
renderer.draw_path(gc, mpath.Path(starts[start_path:end_path]),
mtransforms.Affine2D(), None)
line_shift *= -1
gcs[0].restore()
@exporter.export
class StationaryFrontolysis(Frontolysis):
"""Draw a weakening stationary front.."""
_symbol = WarmFront._symbol
_symbol2 = ColdFront._symbol.transformed(mtransforms.Affine2D().scale(1, -1))
def __init__(self, colors=('red', 'blue'), **kwargs):
"""Initialize a weakening stationary front path effect.
This effect alternates between a warm front and cold front symbol.
Parameters
----------
colors : Sequence[str] or Sequence[tuple[float]]
Matplotlib color identifiers to cycle between on the two different front styles.
Defaults to alternating red and blue.
size : int or float
The size of the markers to plot in points. Defaults to 10.
spacing : int or float
The spacing between markers in normalized coordinates. Defaults to 1.
flip : bool
Whether the symbols should be flipped to the other side of the path. Defaults
to `False`.
"""
self._colors = list(map(mcolors.to_rgba, colors))
self._segment_colors = [
(self._colors[0], self._colors[0]),
(self._colors[0], self._colors[1]),
(self._colors[1], self._colors[1]),
(self._colors[1], self._colors[0])
]
super().__init__(color=self._colors[0], **kwargs)
def draw_path(self, renderer, gc, path, affine, rgbFace=None): # noqa: N803
"""Draw the given path."""
gcs = [self._override_gc(renderer, gc, foreground=color) for color in self._colors]
self._gc_cycle = itertools.cycle(gcs)
self._symbol_cycle = itertools.cycle([self._symbol, self._symbol2])
self._color_cycle = itertools.cycle(self._colors)
self._segment_cycle = itertools.cycle(self._segment_colors)
# Get the information we need for drawing along the path
starts, offsets, angles = self._process_path(path, affine)
# Figure out what segments the markers should be drawn upon, how
# far within that segment the markers will appear, and the segment bounds.
(segment_starts, segment_ends,
segment_indices, marker_offsets) = self._get_path_locations(offsets, renderer)
# Need to account for the line width in order to properly draw symbols at line edge
line_shift = renderer.points_to_pixels(gc.get_linewidth()) / 2
# Loop over all the markers to draw
for ind, marker_offset in zip(segment_indices[::2], marker_offsets[::2], strict=False):
sym_trans = self._get_symbol_transform(renderer, marker_offset, line_shift,
angles[ind], starts[ind])
gc = next(self._gc_cycle)
color = next(self._color_cycle)
symbol = next(self._symbol_cycle)
renderer.draw_path(gc, symbol, sym_trans, color)
line_shift *= -1
for start_path, mid_path, end_path in zip(segment_starts,
segment_indices,
segment_ends, strict=False):
color1, color2 = next(self._segment_cycle)
gcx = self._override_gc(renderer, gc, foreground=mcolors.to_rgb(color1))
renderer.draw_path(gcx, mpath.Path(starts[start_path:mid_path]),
mtransforms.Affine2D(), None)
gcx = self._override_gc(renderer, gc, foreground=mcolors.to_rgb(color2))
renderer.draw_path(gcx, mpath.Path(starts[mid_path:end_path]),
mtransforms.Affine2D(), None)
gcs[0].restore()